From c870e3909a58b0f93aedcd53f7a3745e260c1571 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:58:24 +0300 Subject: [PATCH] Initial --- app/db/seed/index.ts | 17 +- app/db/tables.ts | 8 +- app/features/admin/routes/admin.test.ts | 65 +++-- .../PlusVotingRepository.server.ts | 83 ++++++- .../plus-voting/core/PlusVoting.test.ts | 222 ++++++++++++++++++ app/features/plus-voting/core/PlusVoting.ts | 77 ++++++ .../plus-voting/plus-voting-constants.ts | 18 ++ .../routes/plus.voting.results.tsx | 2 +- migrations/127-plus-voting-criteria.js | 36 +++ 9 files changed, 482 insertions(+), 46 deletions(-) create mode 100644 app/features/plus-voting/core/PlusVoting.test.ts create mode 100644 app/features/plus-voting/core/PlusVoting.ts create mode 100644 migrations/127-plus-voting-criteria.js diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index b9804b401..595eee2fb 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -739,14 +739,15 @@ async function thisMonthsSuggestions() { } } -function syncPlusTiers() { - sql - .prepare( - /* sql */ ` - insert into "PlusTier" ("userId", "tier") select "userId", "tier" from "FreshPlusTier" where "tier" is not null; - `, - ) - .run(); +async function syncPlusTiers() { + const tiers = await PlusVotingRepository.allPlusTiersFromLatestVoting(); + + if (tiers.length === 0) return; + + await db + .insertInto("PlusTier") + .values(tiers.map(({ userId, plusTier }) => ({ userId, tier: plusTier }))) + .execute(); } function getAvailableBadgeIds() { diff --git a/app/db/tables.ts b/app/db/tables.ts index 823535c43..958acc15f 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -225,11 +225,6 @@ export interface CalendarEventResultTeam { placement: number; } -export interface FreshPlusTier { - tier: number | null; - userId: number; -} - export interface Group { chatCode: string | null; createdAt: Generated; @@ -429,7 +424,6 @@ export interface PlusVotingResult { month: number; year: number; wasSuggested: DBBoolean; - passedVoting: DBBoolean; } export interface ReportedWeapon { @@ -1306,7 +1300,7 @@ export interface DB { CalendarEventDate: CalendarEventDate; CalendarEventResultPlayer: CalendarEventResultPlayer; CalendarEventResultTeam: CalendarEventResultTeam; - FreshPlusTier: FreshPlusTier; + Group: Group; GroupLike: GroupLike; GroupMatch: GroupMatch; diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index 2b9fbec85..1c8ab432a 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -91,12 +91,54 @@ describe("Plus voting", () => { expect(await countPlusTierMembers()).toBe(5); }); - test("60% is the criteria to pass voting", async () => { + test("60% or more guarantees pass", async () => { vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z")); await dbInsertUsers(10); - // 50% + // 60% - auto-pass + await PlusVotingRepository.upsertMany( + Array.from({ length: 10 }).map((_, i) => { + return voteArgs({ + authorId: i + 1, + score: i < 4 ? -1 : 1, + votedId: 1, + }); + }), + ); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + expect(await countPlusTierMembers()).toBe(1); + }); + + test("40% or less does not pass", async () => { + vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z")); + + await dbInsertUsers(10); + + // 40% - auto-fail + await PlusVotingRepository.upsertMany( + Array.from({ length: 10 }).map((_, i) => { + return voteArgs({ + authorId: i + 1, + score: i < 6 ? -1 : 1, + votedId: 1, + }); + }), + ); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + expect(await countPlusTierMembers()).toBe(0); + }); + + test("middle zone (40-60%) passes when quota has room", async () => { + vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z")); + + await dbInsertUsers(10); + + // 50% - middle zone, should pass (quota=50 for tier 1) await PlusVotingRepository.upsertMany( Array.from({ length: 10 }).map((_, i) => { return voteArgs({ @@ -106,27 +148,10 @@ describe("Plus voting", () => { }); }), ); - // 60% - await PlusVotingRepository.upsertMany( - Array.from({ length: 10 }).map((_, i) => { - return voteArgs({ - authorId: i + 1, - score: i < 4 ? -1 : 1, - votedId: 2, - }); - }), - ); await adminAction({ _action: "REFRESH" }, { user: "admin" }); - const rows = await db - .selectFrom("PlusTier") - .select(["PlusTier.tier", "PlusTier.userId"]) - .where("PlusTier.tier", "=", 1) - .execute(); - - expect(rows.length).toBe(1); - expect(rows[0].userId).toBe(2); + expect(await countPlusTierMembers()).toBe(1); }); test("combines leaderboard and voting results (after season over)", async () => { diff --git a/app/features/plus-voting/PlusVotingRepository.server.ts b/app/features/plus-voting/PlusVotingRepository.server.ts index 021de7cce..d4e3a1983 100644 --- a/app/features/plus-voting/PlusVotingRepository.server.ts +++ b/app/features/plus-voting/PlusVotingRepository.server.ts @@ -11,6 +11,7 @@ import { import invariant from "~/utils/invariant"; import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; +import * as PlusVoting from "./core/PlusVoting"; const resultsByMonthYearQuery = (args: MonthYear) => db @@ -19,9 +20,9 @@ const resultsByMonthYearQuery = (args: MonthYear) => .select([ ...COMMON_USER_FIELDS, "PlusVotingResult.wasSuggested", - "PlusVotingResult.passedVoting", "PlusVotingResult.tier", "PlusVotingResult.score", + "PlusVotingResult.votedId", ]) .where("PlusVotingResult.month", "=", args.month) .where("PlusVotingResult.year", "=", args.year) @@ -31,26 +32,88 @@ type ResultsByMonthYearQueryReturnType = InferResult< >; export function allPlusTiersFromLatestVoting() { - return db - .selectFrom("FreshPlusTier") - .select(["FreshPlusTier.userId", "FreshPlusTier.tier as plusTier"]) - .where("FreshPlusTier.tier", "is not", null) - .execute() as Promise<{ userId: number; plusTier: number }[]>; + return ( + db + .selectFrom("PlusVotingResult") + .select([ + "PlusVotingResult.votedId", + "PlusVotingResult.tier", + "PlusVotingResult.score", + "PlusVotingResult.wasSuggested", + ]) + .where( + "PlusVotingResult.year", + "=", + db + .selectFrom("PlusVote") + .select("PlusVote.year") + .where("PlusVote.validAfter", "<", sql`strftime('%s', 'now')`) + .orderBy("PlusVote.year", "desc") + .orderBy("PlusVote.month", "desc") + .limit(1), + ) + .where( + "PlusVotingResult.month", + "=", + db + .selectFrom("PlusVote") + .select("PlusVote.month") + .where("PlusVote.validAfter", "<", sql`strftime('%s', 'now')`) + .orderBy("PlusVote.year", "desc") + .orderBy("PlusVote.month", "desc") + .limit(1), + ) + .execute() + // CLAUDETODO: don't use then, just await (assign the value to intermediate variable) + .then((rows) => { + const withPassed = PlusVoting.computePassedVoting(rows); + return PlusVoting.computeFreshPlusTiers(withPassed); + }) + ); } export type ResultsByMonthYearItem = Unwrapped; export async function resultsByMonthYear(args: MonthYear) { const rows = await resultsByMonthYearQuery(args).execute(); - return groupPlusVotingResults(rows); + const passedMap = new Map< + string, + { passedVoting: number; wasSuggested: number } + >(); + const rawForVoting = rows.map((row) => ({ + votedId: row.votedId, + tier: row.tier, + score: row.score, + wasSuggested: row.wasSuggested, + })); + for (const r of PlusVoting.computePassedVoting(rawForVoting)) { + passedMap.set(`${r.votedId}-${r.tier}`, { + passedVoting: r.passedVoting, + wasSuggested: r.wasSuggested, + }); + } + + const enrichedRows = rows.map((row) => { + const computed = passedMap.get(`${row.votedId}-${row.tier}`); + return { + ...row, + passedVoting: computed?.passedVoting ?? 0, + }; + }); + + return groupPlusVotingResults(enrichedRows); } -function groupPlusVotingResults(rows: ResultsByMonthYearQueryReturnType) { +type EnrichedRow = ResultsByMonthYearQueryReturnType[number] & { + passedVoting: number; +}; + +function groupPlusVotingResults(rows: EnrichedRow[]) { const grouped: Record< number, { - passed: ResultsByMonthYearQueryReturnType; - failed: ResultsByMonthYearQueryReturnType; + passed: EnrichedRow[]; + failed: EnrichedRow[]; } > = {}; diff --git a/app/features/plus-voting/core/PlusVoting.test.ts b/app/features/plus-voting/core/PlusVoting.test.ts new file mode 100644 index 000000000..60d768fe1 --- /dev/null +++ b/app/features/plus-voting/core/PlusVoting.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "vitest"; +import { computeFreshPlusTiers, computePassedVoting } from "./PlusVoting"; + +const result = ( + overrides: Partial<{ + votedId: number; + tier: number; + score: number; + wasSuggested: number; + }> = {}, +) => ({ + votedId: 1, + tier: 1, + score: 0, + wasSuggested: 0, + ...overrides, +}); + +describe("computePassedVoting", () => { + test("auto-passes users with 60% or more", () => { + const results = computePassedVoting([ + result({ votedId: 1, score: 0.2 }), + result({ votedId: 2, score: 0.5 }), + result({ votedId: 3, score: 1 }), + ]); + + expect(results.every((r) => r.passedVoting === 1)).toBe(true); + }); + + test("auto-fails users with 40% or less", () => { + const results = computePassedVoting([ + result({ votedId: 1, score: -0.2 }), + result({ votedId: 2, score: -0.5 }), + result({ votedId: 3, score: -1 }), + ]); + + expect(results.every((r) => r.passedVoting === 0)).toBe(true); + }); + + test("auto-passers pass even when exceeding quota", () => { + const autoPassers = Array.from({ length: 100 }, (_, i) => + result({ votedId: i + 1, score: 0.3 }), + ); + + const results = computePassedVoting(autoPassers); + + expect(results.length).toBe(100); + expect(results.every((r) => r.passedVoting === 1)).toBe(true); + }); + + test("middle zone users pass when quota has remaining slots", () => { + const results = computePassedVoting([ + result({ votedId: 1, score: 0.3 }), + result({ votedId: 2, score: 0.1 }), + result({ votedId: 3, score: 0.05 }), + ]); + + expect(results.find((r) => r.votedId === 1)?.passedVoting).toBe(1); + expect(results.find((r) => r.votedId === 2)?.passedVoting).toBe(1); + expect(results.find((r) => r.votedId === 3)?.passedVoting).toBe(1); + }); + + test("nobody from middle zone passes when auto-passers fill the quota", () => { + const autoPassers = Array.from({ length: 50 }, (_, i) => + result({ votedId: i + 1, score: 0.3 }), + ); + const middleZone = Array.from({ length: 10 }, (_, i) => + result({ votedId: 51 + i, score: 0.1 }), + ); + + const results = computePassedVoting([...autoPassers, ...middleZone]); + + const middleResults = results.filter((r) => r.votedId > 50); + expect(middleResults.every((r) => r.passedVoting === 0)).toBe(true); + }); + + test("middle zone fills remaining slots by highest score", () => { + const autoPassers = Array.from({ length: 48 }, (_, i) => + result({ votedId: i + 1, score: 0.3 }), + ); + + const middleZone = [ + result({ votedId: 100, score: 0.15 }), + result({ votedId: 101, score: 0.1 }), + result({ votedId: 102, score: 0.05 }), + result({ votedId: 103, score: 0.0 }), + ]; + + const results = computePassedVoting([...autoPassers, ...middleZone]); + + expect(results.find((r) => r.votedId === 100)?.passedVoting).toBe(1); + expect(results.find((r) => r.votedId === 101)?.passedVoting).toBe(1); + expect(results.find((r) => r.votedId === 102)?.passedVoting).toBe(0); + expect(results.find((r) => r.votedId === 103)?.passedVoting).toBe(0); + }); + + test("different tiers have different quotas", () => { + const tier1AutoPassers = Array.from({ length: 49 }, (_, i) => + result({ votedId: i + 1, tier: 1, score: 0.3 }), + ); + const tier1Middle = [result({ votedId: 200, tier: 1, score: 0.1 })]; + + const tier2AutoPassers = Array.from({ length: 74 }, (_, i) => + result({ votedId: 300 + i, tier: 2, score: 0.3 }), + ); + const tier2Middle = [result({ votedId: 500, tier: 2, score: 0.1 })]; + + const results = computePassedVoting([ + ...tier1AutoPassers, + ...tier1Middle, + ...tier2AutoPassers, + ...tier2Middle, + ]); + + expect(results.find((r) => r.votedId === 200)?.passedVoting).toBe(1); + expect(results.find((r) => r.votedId === 500)?.passedVoting).toBe(1); + }); + + test("exact boundary: score of 0.2 auto-passes", () => { + const results = computePassedVoting([result({ score: 0.2 })]); + + expect(results[0].passedVoting).toBe(1); + }); + + test("exact boundary: score of -0.2 auto-fails", () => { + const results = computePassedVoting([result({ score: -0.2 })]); + + expect(results[0].passedVoting).toBe(0); + }); + + test("empty results returns empty array", () => { + expect(computePassedVoting([])).toEqual([]); + }); +}); + +describe("computeFreshPlusTiers", () => { + const withPassed = ( + overrides: Partial<{ + votedId: number; + tier: number; + score: number; + wasSuggested: number; + passedVoting: number; + }> = {}, + ) => ({ + votedId: 1, + tier: 1, + score: 0.5, + wasSuggested: 0, + passedVoting: 1, + ...overrides, + }); + + test("passed voting keeps tier", () => { + const tiers = computeFreshPlusTiers([ + withPassed({ votedId: 1, tier: 1, passedVoting: 1 }), + ]); + + expect(tiers).toEqual([{ userId: 1, plusTier: 1 }]); + }); + + test("failed + suggested = removed", () => { + const tiers = computeFreshPlusTiers([ + withPassed({ + votedId: 1, + tier: 1, + passedVoting: 0, + wasSuggested: 1, + }), + ]); + + expect(tiers).toEqual([]); + }); + + test("failed + not suggested = demoted one tier", () => { + const tiers = computeFreshPlusTiers([ + withPassed({ + votedId: 1, + tier: 1, + passedVoting: 0, + wasSuggested: 0, + }), + ]); + + expect(tiers).toEqual([{ userId: 1, plusTier: 2 }]); + }); + + test("failed tier 3 + not suggested = removed", () => { + const tiers = computeFreshPlusTiers([ + withPassed({ + votedId: 1, + tier: 3, + passedVoting: 0, + wasSuggested: 0, + }), + ]); + + expect(tiers).toEqual([]); + }); + + test("multi-tier user gets best (lowest) tier", () => { + const tiers = computeFreshPlusTiers([ + withPassed({ votedId: 1, tier: 1, passedVoting: 0, wasSuggested: 0 }), + withPassed({ votedId: 1, tier: 2, passedVoting: 1 }), + ]); + + expect(tiers).toEqual([{ userId: 1, plusTier: 2 }]); + }); + + test("all fail = no tier", () => { + const tiers = computeFreshPlusTiers([ + withPassed({ + votedId: 1, + tier: 3, + passedVoting: 0, + wasSuggested: 1, + }), + ]); + + expect(tiers).toEqual([]); + }); +}); diff --git a/app/features/plus-voting/core/PlusVoting.ts b/app/features/plus-voting/core/PlusVoting.ts new file mode 100644 index 000000000..2cf38b3ce --- /dev/null +++ b/app/features/plus-voting/core/PlusVoting.ts @@ -0,0 +1,77 @@ +import * as R from "remeda"; +import { PLUS_VOTING_CRITERIA } from "../plus-voting-constants"; + +interface RawVotingResult { + votedId: number; + tier: number; + score: number; + wasSuggested: number; +} + +interface VotingResultWithPassed extends RawVotingResult { + passedVoting: number; +} + +export function computePassedVoting( + results: RawVotingResult[], +): VotingResultWithPassed[] { + const byTier = R.groupBy(results, (r) => r.tier); + + return Object.entries(byTier).flatMap(([tierStr, tierResults]) => { + const tier = Number(tierStr) as keyof typeof PLUS_VOTING_CRITERIA; + const criteria = PLUS_VOTING_CRITERIA[tier]; + + const passAvg = percentageToDbAvg(criteria.passPercentage); + const failAvg = percentageToDbAvg(criteria.failPercentage); + + const autoPassers = tierResults.filter((r) => r.score >= passAvg); + const autoFailers = tierResults.filter((r) => r.score <= failAvg); + const middleZone = tierResults + .filter((r) => r.score > failAvg && r.score < passAvg) + .sort((a, b) => b.score - a.score); + + const remainingSlots = Math.max(0, criteria.quota - autoPassers.length); + + return [ + ...autoPassers.map((r) => ({ ...r, passedVoting: 1 as number })), + ...autoFailers.map((r) => ({ ...r, passedVoting: 0 as number })), + ...middleZone.map((r, i) => ({ + ...r, + passedVoting: i < remainingSlots ? (1 as number) : (0 as number), + })), + ]; + }); +} + +export function computeFreshPlusTiers( + results: VotingResultWithPassed[], +): { userId: number; plusTier: number }[] { + const byUser = R.groupBy(results, (r) => r.votedId); + + const output: { userId: number; plusTier: number }[] = []; + + for (const [userIdStr, userResults] of Object.entries(byUser)) { + const effectiveTiers: number[] = []; + + for (const r of userResults) { + if (r.passedVoting) { + effectiveTiers.push(r.tier); + } else if (!r.wasSuggested && r.tier !== 3) { + effectiveTiers.push(r.tier + 1); + } + } + + if (effectiveTiers.length === 0) continue; + + output.push({ + userId: Number(userIdStr), + plusTier: Math.min(...effectiveTiers), + }); + } + + return output; +} + +function percentageToDbAvg(percentage: number) { + return (2 * percentage - 100) / 100; +} diff --git a/app/features/plus-voting/plus-voting-constants.ts b/app/features/plus-voting/plus-voting-constants.ts index 9f704d359..a0f413a65 100644 --- a/app/features/plus-voting/plus-voting-constants.ts +++ b/app/features/plus-voting/plus-voting-constants.ts @@ -1,2 +1,20 @@ export const PLUS_UPVOTE = 1; export const PLUS_DOWNVOTE = -1; + +export const PLUS_VOTING_CRITERIA = { + 1: { + passPercentage: 60, + failPercentage: 40, + quota: 50, + }, + 2: { + passPercentage: 60, + failPercentage: 40, + quota: 75, + }, + 3: { + passPercentage: 60, + failPercentage: 40, + quota: 150, + }, +} as const; diff --git a/app/features/plus-voting/routes/plus.voting.results.tsx b/app/features/plus-voting/routes/plus.voting.results.tsx index 4ed84bab6..fe150ff8b 100644 --- a/app/features/plus-voting/routes/plus.voting.results.tsx +++ b/app/features/plus-voting/routes/plus.voting.results.tsx @@ -44,7 +44,7 @@ export default function PlusVotingResultsPage() { ? `, your score was ${result.score}% ${ result.betterThan ? `(better than ${result.betterThan}% others)` - : "(at least 60% required to pass)" + : "" }` : ""} diff --git a/migrations/127-plus-voting-criteria.js b/migrations/127-plus-voting-criteria.js new file mode 100644 index 000000000..8b7a0a10f --- /dev/null +++ b/migrations/127-plus-voting-criteria.js @@ -0,0 +1,36 @@ +export function up(db) { + db.transaction(() => { + db.prepare(`drop view "FreshPlusTier"`).run(); + db.prepare(`drop view "PlusVotingResult"`).run(); + + db.prepare( + /* sql */ ` + create view "PlusVotingResult" as + select + "votedId", + "tier", + avg("score") as "score", + "month", + "year", + exists ( + select + 1 + from + "PlusSuggestion" + where + "PlusSuggestion"."month" = "PlusVote"."month" + and "PlusSuggestion"."year" = "PlusVote"."year" + and "PlusSuggestion"."suggestedId" = "PlusVote"."votedId" + and "PlusSuggestion"."tier" = "PlusVote"."tier" + ) as "wasSuggested" + from + "PlusVote" + group by + "votedId", + "tier", + "month", + "year"; + `, + ).run(); + })(); +}