mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { type InferResult, sql } from "kysely";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
type MonthYear,
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
import * as PlusVoting from "./core/PlusVoting";
|
||||
|
||||
const resultsByMonthYearQuery = (args: MonthYear) =>
|
||||
db
|
||||
.selectFrom("PlusVotingResult")
|
||||
.innerJoin("User", "PlusVotingResult.votedId", "User.id")
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
"PlusVotingResult.wasSuggested",
|
||||
"PlusVotingResult.tier",
|
||||
"PlusVotingResult.score",
|
||||
"PlusVotingResult.votedId",
|
||||
])
|
||||
.where("PlusVotingResult.month", "=", args.month)
|
||||
.where("PlusVotingResult.year", "=", args.year)
|
||||
.orderBy(sql`"User"."username" collate nocase`, "asc");
|
||||
type ResultsByMonthYearQueryReturnType = InferResult<
|
||||
ReturnType<typeof resultsByMonthYearQuery>
|
||||
>;
|
||||
|
||||
export async function findAllPlusTiersFromLatestVoting() {
|
||||
// resolving month & year separately first allows SQLite to push the
|
||||
// filtering down into the PlusVotingResult view's aggregation
|
||||
const latestVoting = await db
|
||||
.selectFrom("PlusVote")
|
||||
.select(["PlusVote.year", "PlusVote.month"])
|
||||
.where("PlusVote.becomesValidAt", "<", sql<number>`strftime('%s', 'now')`)
|
||||
.orderBy("PlusVote.year", "desc")
|
||||
.orderBy("PlusVote.month", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
const rows = latestVoting
|
||||
? await db
|
||||
.selectFrom("PlusVotingResult")
|
||||
.select([
|
||||
"PlusVotingResult.votedId",
|
||||
"PlusVotingResult.tier",
|
||||
"PlusVotingResult.score",
|
||||
"PlusVotingResult.wasSuggested",
|
||||
])
|
||||
.where("PlusVotingResult.year", "=", latestVoting.year)
|
||||
.where("PlusVotingResult.month", "=", latestVoting.month)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const withPassed = PlusVoting.computePassedVoting(rows);
|
||||
return PlusVoting.computeFreshPlusTiers(withPassed);
|
||||
}
|
||||
|
||||
export type ResultsByMonthYearItem = Unwrapped<typeof findResultsByMonthYear>;
|
||||
export async function findResultsByMonthYear(args: MonthYear) {
|
||||
const rows = await resultsByMonthYearQuery(args).execute();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
type EnrichedRow = ResultsByMonthYearQueryReturnType[number] & {
|
||||
passedVoting: number;
|
||||
};
|
||||
|
||||
function groupPlusVotingResults(rows: EnrichedRow[]) {
|
||||
const grouped: Record<
|
||||
number,
|
||||
{
|
||||
passed: EnrichedRow[];
|
||||
failed: EnrichedRow[];
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const playersOfTier = grouped[row.tier] ?? {
|
||||
passed: [],
|
||||
failed: [],
|
||||
};
|
||||
grouped[row.tier] = playersOfTier;
|
||||
|
||||
playersOfTier[row.passedVoting ? "passed" : "failed"].push(row);
|
||||
}
|
||||
|
||||
return Object.entries(grouped)
|
||||
.map(([tier, { passed, failed }]) => ({
|
||||
tier: Number(tier),
|
||||
passed,
|
||||
failed,
|
||||
}))
|
||||
.sort((a, b) => a.tier - b.tier);
|
||||
}
|
||||
|
||||
export type UsersForVoting = {
|
||||
user: Pick<
|
||||
Tables["User"],
|
||||
"id" | "discordId" | "username" | "discordAvatar" | "bio"
|
||||
> & { customAvatarUrl: string | null };
|
||||
suggestion?: PlusSuggestionRepository.FindAllByMonthItem;
|
||||
}[];
|
||||
|
||||
export async function findAllUsersForVoting(loggedInUser: {
|
||||
id: number;
|
||||
plusTier: number;
|
||||
}) {
|
||||
const members = await db
|
||||
.selectFrom("User")
|
||||
.innerJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.select((eb) => [...commonUserSelect(eb), "User.bio"])
|
||||
.where("PlusTier.tier", "=", loggedInUser.plusTier)
|
||||
.execute();
|
||||
|
||||
const votingRange = nextNonCompletedVoting(new Date());
|
||||
invariant(votingRange, "No next voting found");
|
||||
|
||||
const suggestedUsers = await PlusSuggestionRepository.findAllByMonth({
|
||||
...rangeToMonthYear(votingRange),
|
||||
tier: loggedInUser.plusTier,
|
||||
});
|
||||
|
||||
// the suggestions page does not render bios, so they are not part of a
|
||||
// suggestion and are looked up for the voting page separately
|
||||
const suggestedUserBios = await findBiosByUserIds(
|
||||
suggestedUsers.map((suggestion) => suggestion.suggested.id),
|
||||
);
|
||||
|
||||
const result: UsersForVoting = [];
|
||||
|
||||
for (const member of members) {
|
||||
result.push({
|
||||
user: {
|
||||
id: member.id,
|
||||
discordId: member.discordId,
|
||||
username: member.username,
|
||||
discordAvatar: member.discordAvatar,
|
||||
customAvatarUrl: member.customAvatarUrl,
|
||||
bio: member.bio,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const suggestion of suggestedUsers) {
|
||||
result.push({
|
||||
user: {
|
||||
id: suggestion.suggested.id,
|
||||
discordId: suggestion.suggested.discordId,
|
||||
username: suggestion.suggested.username,
|
||||
discordAvatar: suggestion.suggested.discordAvatar,
|
||||
customAvatarUrl: suggestion.suggested.customAvatarUrl,
|
||||
bio: suggestedUserBios.get(suggestion.suggested.id) ?? null,
|
||||
},
|
||||
suggestion,
|
||||
});
|
||||
}
|
||||
|
||||
return R.shuffle(result.filter(({ user }) => user.id !== loggedInUser.id));
|
||||
}
|
||||
|
||||
export async function hasVoted(args: {
|
||||
authorId: number;
|
||||
month: number;
|
||||
year: number;
|
||||
}) {
|
||||
const rows = await db
|
||||
.selectFrom("PlusVote")
|
||||
.select(({ eb }) => eb.lit(1).as("one"))
|
||||
.where("PlusVote.authorId", "=", args.authorId)
|
||||
.where("PlusVote.month", "=", args.month)
|
||||
.where("PlusVote.year", "=", args.year)
|
||||
.execute();
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export type UpsertManyPlusVotesArgs = Pick<
|
||||
TablesInsertable["PlusVote"],
|
||||
| "month"
|
||||
| "year"
|
||||
| "tier"
|
||||
| "authorId"
|
||||
| "votedId"
|
||||
| "score"
|
||||
| "becomesValidAt"
|
||||
>[];
|
||||
export function upsertMany(votes: UpsertManyPlusVotesArgs) {
|
||||
const firstVote = votes[0];
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.deleteFrom("PlusVote")
|
||||
.where("PlusVote.authorId", "=", firstVote.authorId)
|
||||
.where("PlusVote.month", "=", firstVote.month)
|
||||
.where("PlusVote.year", "=", firstVote.year)
|
||||
.execute();
|
||||
|
||||
await trx.insertInto("PlusVote").values(votes).execute();
|
||||
});
|
||||
}
|
||||
|
||||
async function findBiosByUserIds(userIds: number[]) {
|
||||
if (userIds.length === 0) return new Map<number, string | null>();
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("User")
|
||||
.select(["User.id", "User.bio"])
|
||||
.where("User.id", "in", userIds)
|
||||
.execute();
|
||||
|
||||
return new Map(rows.map((row) => [row.id, row.bio]));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
|
||||
import type { PlusVoteFromFE } from "~/features/plus-voting/core";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { isVotingOpen } from "~/features/plus-voting/core/voting-time";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { badRequestIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { PLUS_UPVOTE } from "../plus-voting-constants";
|
||||
import { votingActionSchema } from "../plus-voting-schemas";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: votingActionSchema,
|
||||
});
|
||||
|
||||
if (!isVotingOpen()) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
invariant(user.plusTier, "User should have plusTier");
|
||||
|
||||
const usersForVoting = await PlusVotingRepository.findAllUsersForVoting({
|
||||
id: user.id,
|
||||
plusTier: user.plusTier,
|
||||
});
|
||||
|
||||
// this should not be needed but makes the voting a bit more resilient
|
||||
// if there is a bug that causes some user to show up twice, or some user to show up who should not be included
|
||||
const seen = new Set<number>();
|
||||
const filteredVotes = data.votes.filter((vote) => {
|
||||
if (seen.has(vote.votedId)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(vote.votedId);
|
||||
return usersForVoting.some((u) => u.user.id === vote.votedId);
|
||||
});
|
||||
|
||||
validateVotes({ votes: filteredVotes, usersForVoting });
|
||||
|
||||
// freebie +1 for yourself if you vote
|
||||
const votesForDb = [...filteredVotes].concat({
|
||||
votedId: user.id,
|
||||
score: PLUS_UPVOTE,
|
||||
});
|
||||
|
||||
const votingRange = badRequestIfFalsy(nextNonCompletedVoting(new Date()));
|
||||
const { month, year } = rangeToMonthYear(votingRange);
|
||||
await PlusVotingRepository.upsertMany(
|
||||
votesForDb.map((vote) => ({
|
||||
...vote,
|
||||
authorId: user.id,
|
||||
month,
|
||||
year,
|
||||
tier: user.plusTier!, // no clue why i couldn't make narrowing the type down above work
|
||||
becomesValidAt: dateToDatabaseTimestamp(votingRange.endDate),
|
||||
})),
|
||||
);
|
||||
|
||||
await resolveNotifications({
|
||||
userIds: [user.id],
|
||||
type: "PLUS_VOTING_STARTED",
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function validateVotes({
|
||||
votes,
|
||||
usersForVoting,
|
||||
}: {
|
||||
votes: PlusVoteFromFE[];
|
||||
usersForVoting?: PlusVotingRepository.UsersForVoting;
|
||||
}) {
|
||||
if (!usersForVoting) throw new Response(null, { status: 400 });
|
||||
|
||||
// converting it to set also handles the check for duplicate ids
|
||||
const votedUserIds = new Set(votes.map((v) => v.votedId));
|
||||
|
||||
if (votedUserIds.size !== usersForVoting.length) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
for (const { user } of usersForVoting) {
|
||||
if (!votedUserIds.has(user.id)) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
246
apps/web-react/app/features/plus-voting/core/PlusVoting.test.ts
Normal file
246
apps/web-react/app/features/plus-voting/core/PlusVoting.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
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("tied users at the slot boundary all pass even if it exceeds the quota", () => {
|
||||
const autoPassers = Array.from({ length: 47 }, (_, i) =>
|
||||
result({ votedId: i + 1, score: 0.3 }),
|
||||
);
|
||||
|
||||
const middleZone = [
|
||||
result({ votedId: 100, score: 0.15 }), // 48
|
||||
result({ votedId: 101, score: 0.1 }), // 49 TIED
|
||||
result({ votedId: 102, score: 0.1 }), // 50 TIED
|
||||
result({ votedId: 103, score: 0.1 }), // 51 TIED
|
||||
result({ votedId: 104, score: 0.1 }), // 52 TIED
|
||||
result({ votedId: 200, score: 0.05 }), // 53
|
||||
];
|
||||
|
||||
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(1);
|
||||
expect(results.find((r) => r.votedId === 103)?.passedVoting).toBe(1);
|
||||
expect(results.find((r) => r.votedId === 104)?.passedVoting).toBe(1);
|
||||
expect(results.find((r) => r.votedId === 200)?.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([]);
|
||||
});
|
||||
});
|
||||
81
apps/web-react/app/features/plus-voting/core/PlusVoting.ts
Normal file
81
apps/web-react/app/features/plus-voting/core/PlusVoting.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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);
|
||||
const lastPassingScore = middleZone[remainingSlots - 1]?.score;
|
||||
|
||||
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 || r.score === lastPassingScore
|
||||
? (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;
|
||||
}
|
||||
8
apps/web-react/app/features/plus-voting/core/index.ts
Normal file
8
apps/web-react/app/features/plus-voting/core/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { MonthYear, PlusVoteFromFE } from "./types";
|
||||
export { usePlusVoting } from "./usePlusVoting";
|
||||
export {
|
||||
isVotingActive,
|
||||
lastCompletedVoting,
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "./voting-time";
|
||||
8
apps/web-react/app/features/plus-voting/core/types.ts
Normal file
8
apps/web-react/app/features/plus-voting/core/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
export interface MonthYear {
|
||||
month: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export type PlusVoteFromFE = Pick<Tables["PlusVote"], "votedId" | "score">;
|
||||
204
apps/web-react/app/features/plus-voting/core/usePlusVoting.ts
Normal file
204
apps/web-react/app/features/plus-voting/core/usePlusVoting.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import * as React from "react";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "../plus-voting-constants";
|
||||
import type { PlusVoteFromFE } from "./types";
|
||||
import { nextNonCompletedVoting, rangeToMonthYear } from "./voting-time";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "plusVoting";
|
||||
|
||||
interface VotingLocalStorageData {
|
||||
month: number;
|
||||
year: number;
|
||||
votes: PlusVoteFromFE[];
|
||||
/** User id -> order for sorting */
|
||||
usersForVotingOrder: Record<Tables["User"]["id"], number>;
|
||||
}
|
||||
|
||||
interface VotingState {
|
||||
usersForVoting: PlusVotingRepository.UsersForVoting;
|
||||
votes: PlusVoteFromFE[];
|
||||
}
|
||||
|
||||
export function usePlusVoting(
|
||||
usersForVotingFromServer: PlusVotingRepository.UsersForVoting,
|
||||
) {
|
||||
const isHydrated = useHydrated();
|
||||
const [state, setState] = React.useState<VotingState | null>(null);
|
||||
|
||||
if (isHydrated && state === null) {
|
||||
setState(resolveInitialVotingState(usersForVotingFromServer));
|
||||
}
|
||||
|
||||
const addVote = React.useCallback((type: "upvote" | "downvote") => {
|
||||
setState((state) => {
|
||||
if (!state) return state;
|
||||
|
||||
const votedId = state.usersForVoting[state.votes.length]?.user.id;
|
||||
if (!votedId) return state;
|
||||
|
||||
const newVotes = [
|
||||
...state.votes,
|
||||
{ votedId, score: type === "upvote" ? PLUS_UPVOTE : PLUS_DOWNVOTE },
|
||||
];
|
||||
|
||||
votesToLocalStorage({
|
||||
usersForVoting: state.usersForVoting,
|
||||
votes: newVotes,
|
||||
});
|
||||
|
||||
return { ...state, votes: newVotes };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const undoLast = React.useCallback(() => {
|
||||
setState((state) => {
|
||||
if (!state) return state;
|
||||
|
||||
const newVotes = state.votes.slice(0, -1);
|
||||
|
||||
votesToLocalStorage({
|
||||
usersForVoting: state.usersForVoting,
|
||||
votes: newVotes,
|
||||
});
|
||||
|
||||
return { ...state, votes: newVotes };
|
||||
});
|
||||
}, []);
|
||||
|
||||
useVoteWithKeysEffect(addVote);
|
||||
|
||||
const usersForVoting = state?.usersForVoting;
|
||||
const votes = state?.votes ?? [];
|
||||
|
||||
const currentUser = usersForVoting?.[votes.length];
|
||||
|
||||
const progress: [currentAmount: number, targetAmount: number] | undefined =
|
||||
usersForVoting ? [votes.length, usersForVoting.length] : undefined;
|
||||
|
||||
return {
|
||||
votes,
|
||||
addVote,
|
||||
undoLast,
|
||||
currentUser,
|
||||
previous: previousUser({ usersForVoting, votes }),
|
||||
isReady: state !== null,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInitialVotingState(
|
||||
usersForVotingFromServer: PlusVotingRepository.UsersForVoting,
|
||||
): VotingState {
|
||||
const range = nextNonCompletedVoting(new Date());
|
||||
invariant(range, "No next voting found");
|
||||
const { month, year } = rangeToMonthYear(range);
|
||||
|
||||
const usersForVotingFromLocalStorage =
|
||||
localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
|
||||
if (!usersForVotingFromLocalStorage) {
|
||||
return { usersForVoting: usersForVotingFromServer, votes: [] };
|
||||
}
|
||||
|
||||
const parsedUsersForVoting = JSON.parse(
|
||||
usersForVotingFromLocalStorage,
|
||||
) as VotingLocalStorageData;
|
||||
|
||||
if (
|
||||
parsedUsersForVoting.month !== month ||
|
||||
parsedUsersForVoting.year !== year
|
||||
) {
|
||||
return { usersForVoting: usersForVotingFromServer, votes: [] };
|
||||
}
|
||||
|
||||
// bit of defensive coding in case for some reason the local storage data is out of date
|
||||
try {
|
||||
const sortedUsersForVoting = [...usersForVotingFromServer].sort((a, b) => {
|
||||
const aOrder = parsedUsersForVoting.usersForVotingOrder[a.user.id];
|
||||
const bOrder = parsedUsersForVoting.usersForVotingOrder[b.user.id];
|
||||
|
||||
if (typeof aOrder !== "number") {
|
||||
throw new Error(`No order for user with id ${a.user.id}`);
|
||||
}
|
||||
if (typeof bOrder !== "number") {
|
||||
throw new Error(`No order for user with id ${b.user.id}`);
|
||||
}
|
||||
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
return {
|
||||
usersForVoting: sortedUsersForVoting,
|
||||
votes: parsedUsersForVoting.votes,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return { usersForVoting: usersForVotingFromServer, votes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function useVoteWithKeysEffect(vote: (type: "upvote" | "downvote") => void) {
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "KeyS") {
|
||||
vote("downvote");
|
||||
} else if (e.code === "KeyK") {
|
||||
vote("upvote");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [vote]);
|
||||
}
|
||||
|
||||
function previousUser({
|
||||
usersForVoting,
|
||||
votes,
|
||||
}: {
|
||||
usersForVoting?: PlusVotingRepository.UsersForVoting;
|
||||
votes: PlusVoteFromFE[];
|
||||
}) {
|
||||
if (!usersForVoting) return;
|
||||
|
||||
const previousUser = usersForVoting?.[votes.length - 1];
|
||||
if (!previousUser) return;
|
||||
|
||||
const previousScore = votes[votes.length - 1]?.score;
|
||||
invariant(previousScore);
|
||||
|
||||
return {
|
||||
...previousUser,
|
||||
score: previousScore,
|
||||
};
|
||||
}
|
||||
|
||||
function votesToLocalStorage({
|
||||
usersForVoting,
|
||||
votes,
|
||||
}: {
|
||||
usersForVoting: PlusVotingRepository.UsersForVoting;
|
||||
votes: PlusVoteFromFE[];
|
||||
}) {
|
||||
const range = nextNonCompletedVoting(new Date());
|
||||
invariant(range, "No next voting found");
|
||||
|
||||
const { month, year } = rangeToMonthYear(range);
|
||||
|
||||
const toLocalStorage: VotingLocalStorageData = {
|
||||
month,
|
||||
year,
|
||||
votes,
|
||||
usersForVotingOrder: Object.fromEntries(
|
||||
usersForVoting.map(({ user }, i) => [user.id, i]),
|
||||
),
|
||||
};
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(toLocalStorage));
|
||||
}
|
||||
88
apps/web-react/app/features/plus-voting/core/voting-time.ts
Normal file
88
apps/web-react/app/features/plus-voting/core/voting-time.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Config } from "~/config";
|
||||
import * as Seasons from "../../mmr/core/Seasons";
|
||||
import type { MonthYear } from "./types";
|
||||
|
||||
/** In local development voting is always open so that the voting page can be tested outside a voting window. */
|
||||
const VOTING_ALWAYS_OPEN =
|
||||
process.env.NODE_ENV === "development" && !Config.prodMode;
|
||||
|
||||
export function lastCompletedVoting(now: Date): MonthYear {
|
||||
let match: { startDate: Date; endDate: Date } | null = null;
|
||||
for (const season of Seasons.list) {
|
||||
const range = seasonToVotingRange(season);
|
||||
|
||||
if (now.getTime() > range.endDate.getTime()) {
|
||||
match = range;
|
||||
} else if (now.getTime() < range.endDate.getTime()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
throw new Error("No previous voting found.");
|
||||
}
|
||||
|
||||
return rangeToMonthYear(match);
|
||||
}
|
||||
|
||||
export function nextNonCompletedVoting(now: Date) {
|
||||
for (const season of Seasons.list) {
|
||||
const range = seasonToVotingRange(season);
|
||||
|
||||
if (now.getTime() < range.endDate.getTime()) {
|
||||
return range;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rangeToMonthYear(range: { startDate: Date; endDate: Date }) {
|
||||
return {
|
||||
month: range.startDate.getMonth(),
|
||||
year: range.startDate.getFullYear(),
|
||||
};
|
||||
}
|
||||
|
||||
export function seasonToVotingRange(season: Seasons.ListItem) {
|
||||
const { ends: date } = season;
|
||||
|
||||
if (date.getUTCDay() !== 0) {
|
||||
throw new Error("End date is not a Sunday.");
|
||||
}
|
||||
|
||||
const endDate = new Date(date);
|
||||
endDate.setUTCDate(endDate.getUTCDate() - 7);
|
||||
endDate.setUTCHours(18, 0, 0, 0);
|
||||
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setUTCDate(startDate.getUTCDate() - 2);
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether votes can be cast right now. Unlike {@link isVotingActive}, which also decides when
|
||||
* suggesting is locked, this is always true in local development so the voting page can be
|
||||
* tested outside an actual voting window.
|
||||
*/
|
||||
export function isVotingOpen() {
|
||||
return VOTING_ALWAYS_OPEN || isVotingActive();
|
||||
}
|
||||
|
||||
export function isVotingActive() {
|
||||
const now = new Date();
|
||||
|
||||
for (const season of Seasons.list) {
|
||||
const { startDate, endDate } = seasonToVotingRange(season);
|
||||
|
||||
if (
|
||||
now.getTime() > startDate.getTime() &&
|
||||
now.getTime() < endDate.getTime()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { lastCompletedVoting } from "~/features/plus-voting/core";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { UserWithPlusTier } from "~/utils/kysely.server";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
|
||||
export const loader = async () => {
|
||||
const user = getUser();
|
||||
const results = await PlusVotingRepository.findResultsByMonthYear(
|
||||
lastCompletedVoting(new Date()),
|
||||
);
|
||||
|
||||
return {
|
||||
results: censorScores(results),
|
||||
ownScores: ownScores({ results, user }),
|
||||
lastCompletedVoting: lastCompletedVoting(new Date()),
|
||||
};
|
||||
};
|
||||
|
||||
function censorScores(results: PlusVotingRepository.ResultsByMonthYearItem[]) {
|
||||
return results.map((tier) => ({
|
||||
...tier,
|
||||
passed: tier.passed.map((result) => ({
|
||||
...result,
|
||||
score: undefined,
|
||||
})),
|
||||
failed: tier.failed.map((result) => ({
|
||||
...result,
|
||||
score: undefined,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function ownScores({
|
||||
results,
|
||||
user,
|
||||
}: {
|
||||
results: PlusVotingRepository.ResultsByMonthYearItem[];
|
||||
user?: Pick<UserWithPlusTier, "id" | "patronTier">;
|
||||
}) {
|
||||
return results
|
||||
.flatMap((tier) => [...tier.failed, ...tier.passed])
|
||||
.filter((result) => {
|
||||
return result.id === user?.id;
|
||||
})
|
||||
.map((result) => {
|
||||
const showScore =
|
||||
(result.wasSuggested && !result.passedVoting) || isSupporter(user);
|
||||
|
||||
const resultsOfOwnTierExcludingOwn = () => {
|
||||
const ownTierResults = results.find(
|
||||
(tier) => tier.tier === result.tier,
|
||||
);
|
||||
invariant(ownTierResults, "own tier results not found");
|
||||
|
||||
return [...ownTierResults.failed, ...ownTierResults.passed].filter(
|
||||
(otherResult) => otherResult.id !== result.id,
|
||||
);
|
||||
};
|
||||
|
||||
const mappedResult: {
|
||||
tier: number;
|
||||
score?: number;
|
||||
passedVoting: number;
|
||||
betterThan?: number;
|
||||
} = {
|
||||
tier: result.tier,
|
||||
score: databaseAvgToPercentage(result.score),
|
||||
passedVoting: result.passedVoting,
|
||||
betterThan: roundToNDecimalPlaces(
|
||||
(resultsOfOwnTierExcludingOwn().filter(
|
||||
(otherResult) => otherResult.score <= result.score,
|
||||
).length /
|
||||
resultsOfOwnTierExcludingOwn().length) *
|
||||
100,
|
||||
),
|
||||
};
|
||||
|
||||
if (!showScore) mappedResult.score = undefined;
|
||||
if (!isSupporter(user) || !result.passedVoting) {
|
||||
mappedResult.betterThan = undefined;
|
||||
}
|
||||
|
||||
return mappedResult;
|
||||
});
|
||||
}
|
||||
|
||||
function databaseAvgToPercentage(score: number) {
|
||||
const scoreNormalized = score + 1;
|
||||
|
||||
return roundToNDecimalPlaces((scoreNormalized / 2) * 100);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { formatDistance } from "date-fns";
|
||||
import type { LoaderFunction } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { isVotingOpen } from "~/features/plus-voting/core/voting-time";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import type { UserCardData } from "~/features/user-card/user-card-types";
|
||||
|
||||
export type PlusVotingLoaderData =
|
||||
// next voting date is not in the system
|
||||
| {
|
||||
type: "noTimeDefinedInfo";
|
||||
}
|
||||
// voting is not active OR user is not eligible to vote
|
||||
| {
|
||||
type: "timeInfo";
|
||||
voted?: boolean;
|
||||
timeInfo: {
|
||||
timestamp: number;
|
||||
timing: "starts" | "ends";
|
||||
relativeTime: string;
|
||||
};
|
||||
}
|
||||
// user can vote
|
||||
| {
|
||||
type: "voting";
|
||||
usersForVoting: PlusVotingRepository.UsersForVoting;
|
||||
userCards: Map<number, UserCardData>;
|
||||
votingEnds: {
|
||||
timestamp: number;
|
||||
relativeTime: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async () => {
|
||||
const user = getUser();
|
||||
|
||||
const now = new Date();
|
||||
const nextVotingRange = nextNonCompletedVoting(now);
|
||||
|
||||
if (!nextVotingRange) {
|
||||
return { type: "noTimeDefinedInfo" };
|
||||
}
|
||||
|
||||
if (!isVotingOpen()) {
|
||||
return {
|
||||
type: "timeInfo",
|
||||
timeInfo: {
|
||||
relativeTime: formatDistance(nextVotingRange.startDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timestamp: nextVotingRange.startDate.getTime(),
|
||||
timing: "starts",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const usersForVoting = user?.plusTier
|
||||
? await PlusVotingRepository.findAllUsersForVoting({
|
||||
id: user.id,
|
||||
plusTier: user.plusTier,
|
||||
})
|
||||
: undefined;
|
||||
const hasVoted = user
|
||||
? await PlusVotingRepository.hasVoted({
|
||||
authorId: user.id,
|
||||
...rangeToMonthYear(nextVotingRange),
|
||||
})
|
||||
: false;
|
||||
|
||||
if (!usersForVoting || hasVoted) {
|
||||
return {
|
||||
type: "timeInfo",
|
||||
voted: hasVoted,
|
||||
timeInfo: {
|
||||
relativeTime: formatDistance(nextVotingRange.endDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timestamp: nextVotingRange.endDate.getTime(),
|
||||
timing: "ends",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cardUserIds = R.unique(
|
||||
usersForVoting.flatMap(({ user, suggestion }) => [
|
||||
user.id,
|
||||
...(suggestion?.entries ?? []).map((entry) => entry.author.id),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "voting",
|
||||
usersForVoting,
|
||||
...(await UserCardRepository.findAllByUserIds({ userIds: cardUserIds })),
|
||||
votingEnds: {
|
||||
timestamp: nextVotingRange.endDate.getTime(),
|
||||
relativeTime: formatDistance(nextVotingRange.endDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +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;
|
||||
@@ -0,0 +1,74 @@
|
||||
.ownScores {
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-box);
|
||||
margin: 0 auto;
|
||||
background-color: var(--color-bg-high);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
list-style-type: none;
|
||||
padding-inline: var(--s-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.fail {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.tierHeader {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: var(--color-text-accent);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
flex: 1 1;
|
||||
border-bottom: 3px solid var(--color-bg-high);
|
||||
margin: auto;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&::before {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.passedInfoContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.passedHeader {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.userStatus {
|
||||
border-radius: var(--radius-selector);
|
||||
height: var(--selector-size-sm);
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
padding-inline: var(--s-1-5);
|
||||
}
|
||||
|
||||
.userStatusFailed {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
.suggestionS {
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
margin-inline-end: var(--s-1);
|
||||
padding-inline: var(--s-1);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import type { PlusVoteFromFE } from "~/features/plus-voting/core";
|
||||
import { assertType } from "~/utils/types";
|
||||
import { safeJSONParse } from "~/utils/zod";
|
||||
import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "./plus-voting-constants";
|
||||
|
||||
const voteSchema = z.object({
|
||||
votedId: z.number(),
|
||||
score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)),
|
||||
});
|
||||
|
||||
assertType<z.infer<typeof voteSchema>, PlusVoteFromFE>();
|
||||
|
||||
export const votingActionSchema = z.object({
|
||||
votes: z.preprocess(safeJSONParse, z.array(voteSchema)),
|
||||
});
|
||||
22
apps/web-react/app/features/plus-voting/routes/plus.list.ts
Normal file
22
apps/web-react/app/features/plus-voting/routes/plus.list.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { LoaderFunction } from "react-router";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { canAccessLohiEndpoint } from "~/utils/remix.server";
|
||||
|
||||
export interface PlusListLoaderData {
|
||||
users: Record<string, number>;
|
||||
}
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
if (!canAccessLohiEndpoint(request)) {
|
||||
throw new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
return {
|
||||
users: Object.fromEntries(
|
||||
(await UserRepository.findAllPlusServerMembers()).map((u) => [
|
||||
u.discordId,
|
||||
u.plusTier,
|
||||
]),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import clsx from "clsx";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { metaTags, type SerializeFrom } from "~/utils/remix";
|
||||
import { PLUS_SERVER_DISCORD_URL, userPage } from "~/utils/urls";
|
||||
|
||||
import { loader } from "../loaders/plus.voting.results.server";
|
||||
import styles from "../plus-voting-results.module.css";
|
||||
|
||||
export { loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Plus Server voting results",
|
||||
ogTitle: "Plus Server voting results",
|
||||
description:
|
||||
"Plus Server (+1, +2 and +3) voting results for the latest season.",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function PlusVotingResultsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<h2 className="text-center">
|
||||
Voting results for{" "}
|
||||
<LocaleTime
|
||||
date={
|
||||
new Date(
|
||||
data.lastCompletedVoting.year,
|
||||
data.lastCompletedVoting.month,
|
||||
)
|
||||
}
|
||||
options={{
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
}}
|
||||
inline
|
||||
/>
|
||||
</h2>
|
||||
{data.ownScores && data.ownScores.length > 0 ? (
|
||||
<>
|
||||
<ul className={clsx(styles.ownScores, "stack sm")}>
|
||||
{data.ownScores.map((result) => (
|
||||
<li key={result.tier}>
|
||||
You{" "}
|
||||
{result.passedVoting ? (
|
||||
<span className={styles.success}>passed</span>
|
||||
) : (
|
||||
<span className={styles.fail}>didn't pass</span>
|
||||
)}{" "}
|
||||
the +{result.tier} voting
|
||||
{typeof result.score === "number"
|
||||
? `, your score was ${result.score}% ${
|
||||
result.betterThan
|
||||
? `(better than ${result.betterThan}% others)`
|
||||
: ""
|
||||
}`
|
||||
: ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="text-sm text-center text-lighter">
|
||||
<a href={PLUS_SERVER_DISCORD_URL} target="_blank" rel="noreferrer">
|
||||
Join the Discord server
|
||||
</a>{" "}
|
||||
to get the correct role. In some cases you might need to rejoin the
|
||||
server.
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{!data.ownScores ? (
|
||||
<div className="text-center text-sm">
|
||||
You weren't in the voting this month.
|
||||
</div>
|
||||
) : null}
|
||||
{data.results ? <Results results={data.results} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Results({
|
||||
results,
|
||||
}: {
|
||||
results: NonNullable<SerializeFrom<typeof loader>["results"]>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-lighter">S = Suggested user</div>
|
||||
<div className="stack lg">
|
||||
{results.map((tiersResults) => (
|
||||
<div className="stack md" key={tiersResults.tier}>
|
||||
<h3 className={styles.tierHeader}>
|
||||
<span>+{tiersResults.tier}</span>
|
||||
</h3>
|
||||
{(["passed", "failed"] as const).map((status) => (
|
||||
<div key={status} className={styles.passedInfoContainer}>
|
||||
<h4 className={styles.passedHeader}>
|
||||
{status === "passed" ? "Passed" : "Didn't pass"} (
|
||||
{tiersResults[status].length})
|
||||
</h4>
|
||||
{tiersResults[status].map((user) => (
|
||||
<Link
|
||||
to={userPage(user)}
|
||||
className={clsx(styles.userStatus, {
|
||||
[styles.userStatusFailed]: status === "failed",
|
||||
})}
|
||||
key={user.id}
|
||||
>
|
||||
{user.wasSuggested ? (
|
||||
<span className={styles.suggestionS}>S</span>
|
||||
) : null}
|
||||
{user.username}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
apps/web-react/app/features/plus-voting/routes/plus.voting.tsx
Normal file
178
apps/web-react/app/features/plus-voting/routes/plus.voting.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import clsx from "clsx";
|
||||
import { Check } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Form, useLoaderData } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { RelativeTime } from "~/components/RelativeTime";
|
||||
import styles from "~/features/plus-suggestions/plus.module.css";
|
||||
import { usePlusVoting } from "~/features/plus-voting/core";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { PlusSuggestionComments } from "../../plus-suggestions/routes/plus.suggestions";
|
||||
|
||||
import { action } from "../actions/plus.voting.server";
|
||||
import {
|
||||
loader,
|
||||
type PlusVotingLoaderData,
|
||||
} from "../loaders/plus.voting.server";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Plus Server Voting",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function PlusVotingPage() {
|
||||
const data = useLoaderData<PlusVotingLoaderData>();
|
||||
|
||||
switch (data.type) {
|
||||
case "noTimeDefinedInfo": {
|
||||
return (
|
||||
<div className="text-center text-lighter text-sm">
|
||||
Next voting date to be announced
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "timeInfo": {
|
||||
return <VotingTimingInfo {...data} />;
|
||||
}
|
||||
case "voting": {
|
||||
return <Voting {...data} />;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function VotingTimingInfo(
|
||||
data: Extract<PlusVotingLoaderData, { type: "timeInfo" }>,
|
||||
) {
|
||||
return (
|
||||
<div className="stack md">
|
||||
{data.voted ? (
|
||||
<div className={styles.votingAlert}>
|
||||
<Check /> You have voted
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm text-center">
|
||||
{data.timeInfo.timing === "starts"
|
||||
? "Next voting starts"
|
||||
: "Voting is currently happening. Ends"}{" "}
|
||||
<RelativeTime timestamp={data.timeInfo.timestamp}>
|
||||
{data.timeInfo.relativeTime}
|
||||
</RelativeTime>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tips = [
|
||||
"Voting progress is saved locally",
|
||||
"You can use S (-1) and K (+1) keys on desktop to vote",
|
||||
"You +1 yourself automatically",
|
||||
];
|
||||
|
||||
function Voting(data: Extract<PlusVotingLoaderData, { type: "voting" }>) {
|
||||
const [randomTip] = React.useState(tips[Math.floor(Math.random() * 3)]);
|
||||
const { currentUser, previous, votes, addVote, undoLast, isReady, progress } =
|
||||
usePlusVoting(data.usersForVoting);
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.votingContainer, "stack md")}>
|
||||
<div className="stack xs">
|
||||
<div className="text-sm text-center">
|
||||
Voting ends{" "}
|
||||
<RelativeTime timestamp={data.votingEnds.timestamp}>
|
||||
{data.votingEnds.relativeTime}
|
||||
</RelativeTime>
|
||||
</div>
|
||||
{progress ? (
|
||||
<progress
|
||||
className={styles.votingProgress}
|
||||
value={progress[0]}
|
||||
max={progress[1]}
|
||||
title={`Voting progress ${progress[0]} out of ${progress[1]}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{previous ? (
|
||||
<p className="button-text-paragraph text-sm text-lighter">
|
||||
Previously{" "}
|
||||
<span className={previous.score > 0 ? "text-success" : "text-error"}>
|
||||
{previous.score > 0 ? "+" : ""}
|
||||
{previous.score}
|
||||
</span>{" "}
|
||||
on {previous.user.username}.
|
||||
<SendouButton
|
||||
className="ml-auto"
|
||||
variant="minimal"
|
||||
onPress={undoLast}
|
||||
>
|
||||
Undo?
|
||||
</SendouButton>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-lighter">Tip: {randomTip}</p>
|
||||
)}
|
||||
{currentUser ? (
|
||||
<div className="stack md items-center">
|
||||
<h2>
|
||||
<UserCard userId={currentUser.user.id}>
|
||||
<span className={styles.votingUserTrigger}>
|
||||
<Avatar user={currentUser.user} size="lg" />
|
||||
{currentUser.user.username}
|
||||
</span>
|
||||
</UserCard>
|
||||
</h2>
|
||||
<div className="stack horizontal lg">
|
||||
<SendouButton
|
||||
className={clsx(
|
||||
styles.votingVoteButton,
|
||||
styles.votingVoteButtonDownvote,
|
||||
)}
|
||||
variant="outlined"
|
||||
onPress={() => addVote("downvote")}
|
||||
>
|
||||
-1
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
className={styles.votingVoteButton}
|
||||
variant="outlined"
|
||||
onPress={() => addVote("upvote")}
|
||||
>
|
||||
+1
|
||||
</SendouButton>
|
||||
</div>
|
||||
{currentUser.suggestion ? (
|
||||
<PlusSuggestionComments
|
||||
suggestion={currentUser.suggestion}
|
||||
defaultOpen
|
||||
/>
|
||||
) : null}
|
||||
{currentUser.user.bio ? (
|
||||
<article className="w-full">
|
||||
<h2 className={styles.votingBioHeader}>Bio</h2>
|
||||
{currentUser.user.bio}
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Form method="post">
|
||||
<input type="hidden" name="votes" value={JSON.stringify(votes)} />
|
||||
<SendouButton className={styles.votingSubmitButton} type="submit">
|
||||
Submit votes
|
||||
</SendouButton>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user