This commit is contained in:
Kalle 2026-05-01 10:28:47 +02:00 committed by GitHub
commit 8553df2e99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 482 additions and 46 deletions

View File

@ -1094,14 +1094,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() {

View File

@ -226,11 +226,6 @@ export interface CalendarEventResultTeam {
placement: number;
}
export interface FreshPlusTier {
tier: number | null;
userId: number;
}
export interface Group {
chatCode: string | null;
createdAt: Generated<number>;
@ -434,7 +429,6 @@ export interface PlusVotingResult {
month: number;
year: number;
wasSuggested: DBBoolean;
passedVoting: DBBoolean;
}
export interface ReportedWeapon {
@ -1346,7 +1340,7 @@ export interface DB {
CalendarEventDate: CalendarEventDate;
CalendarEventResultPlayer: CalendarEventResultPlayer;
CalendarEventResultTeam: CalendarEventResultTeam;
FreshPlusTier: FreshPlusTier;
Group: Group;
GroupLike: GroupLike;
GroupMatch: GroupMatch;

View File

@ -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 () => {

View File

@ -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<number>`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<number>`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<typeof resultsByMonthYear>;
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[];
}
> = {};

View File

@ -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([]);
});
});

View File

@ -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;
}

View File

@ -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;

View File

@ -55,7 +55,7 @@ export default function PlusVotingResultsPage() {
? `, your score was ${result.score}% ${
result.betterThan
? `(better than ${result.betterThan}% others)`
: "(at least 60% required to pass)"
: ""
}`
: ""}
</li>

View File

@ -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();
})();
}