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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user