diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 7ea93a010..ecd849e4c 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -25,7 +25,7 @@ export function Avatar({ className={clsx("avatar", className)} src={ discordAvatar - ? `https://cdn.discordapp.com/avatars/${discordId}/${discordAvatar}.png${ + ? `https://cdn.discordapp.com/avatars/${discordId}/${discordAvatar}.webp${ size === "lg" ? "" : "?size=80" }` : "/img/blank.gif" // avoid broken image placeholder diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index 568a8a4f5..1d3cf3b07 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -5,7 +5,7 @@ import clsx from "clsx"; import type { Unpacked } from "~/utils/types"; import { useFetcher } from "@remix-run/react"; import type { UsersLoaderData } from "~/routes/users"; -import type { User } from "~/db/types"; +import type { UserWithPlusTier } from "~/db/types"; const MAX_RESULTS_SHOWN = 6; @@ -97,7 +97,7 @@ export function UserCombobox({ inputName, onChange, }: Pick< - ComboboxProps>, + ComboboxProps>, "inputName" | "onChange" >) { const fetcher = useFetcher(); diff --git a/app/db/index.ts b/app/db/index.ts index 2f11fadab..f0ceb4bd3 100644 --- a/app/db/index.ts +++ b/app/db/index.ts @@ -1,7 +1,9 @@ import * as users from "./models/users.server"; import * as plusSuggestions from "./models/plusSuggestions.server"; +import * as plusVotes from "./models/plusVotes.server"; export const db = { users, plusSuggestions, + plusVotes, }; diff --git a/app/db/models/plusSuggestions.server.ts b/app/db/models/plusSuggestions.server.ts index e9644e3ac..93f30f904 100644 --- a/app/db/models/plusSuggestions.server.ts +++ b/app/db/models/plusSuggestions.server.ts @@ -1,6 +1,6 @@ import type { MonthYear } from "~/core/plus"; import { sql } from "../sql"; -import type { PlusSuggestion, User } from "../types"; +import type { PlusSuggestion, User, UserWithPlusTier } from "../types"; const createStm = sql.prepare(` INSERT INTO @@ -79,7 +79,7 @@ export interface FindVisibleForUser { } export function findVisibleForUser( - args: MonthYear & Pick + args: MonthYear & Pick ): FindVisibleForUser | undefined { if (!args.plusTier) return; return sortNewestPlayersToBeSuggestedFirst( diff --git a/app/db/models/plusVotes.server.ts b/app/db/models/plusVotes.server.ts new file mode 100644 index 000000000..d2f102717 --- /dev/null +++ b/app/db/models/plusVotes.server.ts @@ -0,0 +1,40 @@ +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { sql } from "../sql"; +import type { PlusVote } from "../types"; + +const createStm = sql.prepare(` + INSERT INTO + "PlusVote" ( + "month", + "year", + "tier", + "authorId", + "votedId", + "score", + "validAfter" + ) + VALUES + ( + $month, + $year, + $tier, + $authorId, + $votedId, + $score, + $validAfter + ) +`); + +export type CreateManyPlusVotesArgs = (Pick< + PlusVote, + "month" | "year" | "tier" | "authorId" | "votedId" | "score" +> & { validAfter: Date })[]; +export const createMany = sql.transaction((votes: CreateManyPlusVotesArgs) => { + for (const vote of votes) { + const { validAfter, ...rest } = vote; + createStm.run({ + ...rest, + validAfter: dateToDatabaseTimestamp(validAfter), + }); + } +}); diff --git a/app/db/models/users.server.ts b/app/db/models/users.server.ts index 231e50005..3819bc808 100644 --- a/app/db/models/users.server.ts +++ b/app/db/models/users.server.ts @@ -1,5 +1,5 @@ import { sql } from "../sql"; -import type { User } from "../types"; +import type { User, UserWithPlusTier } from "../types"; const upsertStm = sql.prepare(` INSERT INTO @@ -58,24 +58,28 @@ export function updateProfile(params: Pick) { } const findByIdentifierStm = sql.prepare(` - SELECT * + SELECT "User".*, "PlusTier".tier as "plusTier" FROM "User" + LEFT JOIN "PlusTier" ON "PlusTier"."userId" = "User"."id" WHERE "discordId" = $identifier OR "id" = $identifier `); export function findByIdentifier(identifier: string | number) { - return findByIdentifierStm.get({ identifier }) as User | undefined; + return findByIdentifierStm.get({ identifier }) as + | UserWithPlusTier + | undefined; } const findAllStm = sql.prepare(` - SELECT "id", "discordId", "discordName", "discordDiscriminator", "plusTier" + SELECT "User"."id", "User"."discordId", "User"."discordName", "User"."discordDiscriminator", "PlusTier".tier as "plusTier" FROM "User" + LEFT JOIN "PlusTier" ON "PlusTier"."userId" = "User"."id" `); export function findAll() { return findAllStm.all() as Pick< - User, + UserWithPlusTier, "id" | "discordId" | "discordName" | "discordDiscriminator" | "plusTier" >[]; } diff --git a/app/db/seed.ts b/app/db/seed.ts index e86c4387f..6ca66589c 100644 --- a/app/db/seed.ts +++ b/app/db/seed.ts @@ -1,9 +1,8 @@ +import { faker } from "@faker-js/faker"; +import { lastCompletedVoting } from "~/core/plus"; import { db } from "~/db"; import { sql } from "~/db/sql"; -import { faker } from "@faker-js/faker"; -import type { User } from "~/db/types"; -import invariant from "tiny-invariant"; -import { upcomingVoting } from "~/core/plus"; +import type { CreateManyPlusVotesArgs } from "./models/plusVotes.server"; const ADMIN_TEST_DISCORD_ID = "79237403620945920"; const ADMIN_TEST_AVATAR = "fcfd65a3bea598905abb9ca25296816b"; @@ -15,8 +14,8 @@ const basicSeeds = [ adminUser, nzapUser, users, - plusTierToUsers, - plusSuggestions, + initialPlusMembers, + // thisMonthsSuggestions, ]; export function seed() { @@ -74,37 +73,57 @@ function fakeUser() { }; } -function plusTierToUsers() { - sql.prepare(`update "User" set "plusTier" = 3 where id < 150`).run(); - sql.prepare(`update "User" set "plusTier" = 2 where id < 80`).run(); - sql.prepare(`update "User" set "plusTier" = 1 where id < 30`).run(); +function initialPlusMembers() { + const votes: CreateManyPlusVotesArgs = []; - // omit N-ZAP user for testing - sql.prepare(`update "User" set "plusTier" = null where id = 2`).run(); -} + const { month, year } = lastCompletedVoting(new Date()); -function plusSuggestions() { - const usersInPlus = sql - .prepare(`select * from "User" where "plusTier" is not null and "id" != 1`) // exclude admin - .all() as User[]; - const { month, year } = upcomingVoting(new Date()); + const tier = (id: number) => { + if (id < 30) return 1; + if (id < 80) return 2; - for (let userId = 150; userId < 190; userId++) { - const amountOfSuggestions = faker.helpers.arrayElement([1, 1, 2, 3, 4]); + return 3; + }; - for (let i = 0; i < amountOfSuggestions; i++) { - const suggester = usersInPlus.shift(); - invariant(suggester); - invariant(suggester.plusTier); + for (let id = 1; id < 151; id++) { + if (id === 2) continue; // omit N-ZAP user for testing; - db.plusSuggestions.create({ - authorId: suggester.id, - month, - year, - suggestedId: userId, - text: faker.lorem.lines(), - tier: suggester.plusTier, - }); - } + votes.push({ + authorId: 1, + month, + year, + score: 1, + tier: tier(id), + validAfter: new Date(), + votedId: id, + }); } + + db.plusVotes.createMany(votes); } + +// function thisMonthsSuggestions() { +// const usersInPlus = sql +// .prepare(`select * from "User" where "plusTier" is not null and "id" != 1`) // exclude admin +// .all() as User[]; +// const { month, year } = upcomingVoting(new Date()); + +// for (let userId = 150; userId < 190; userId++) { +// const amountOfSuggestions = faker.helpers.arrayElement([1, 1, 2, 3, 4]); + +// for (let i = 0; i < amountOfSuggestions; i++) { +// const suggester = usersInPlus.shift(); +// invariant(suggester); +// invariant(suggester.plusTier); + +// db.plusSuggestions.create({ +// authorId: suggester.id, +// month, +// year, +// suggestedId: userId, +// text: faker.lorem.lines(), +// tier: suggester.plusTier, +// }); +// } +// } +// } diff --git a/app/db/types.ts b/app/db/types.ts index 3ff9bd73b..7d565833f 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -9,8 +9,11 @@ export interface User { youtubeId: string | null; bio: string | null; country: string | null; - // xxx: problem with "votes that you don't have to end"... can we calcualte dynamically? - plusTier: number | null; +} + +/** User table after joined with PlusTier table */ +export interface UserWithPlusTier extends User { + plusTier: PlusTier["tier"] | null; } export interface PlusSuggestion { @@ -31,4 +34,10 @@ export interface PlusVote { authorId: number; votedId: number; score: number; + validAfter: number; +} + +export interface PlusTier { + userId: number; + tier: number; } diff --git a/app/permissions.ts b/app/permissions.ts index 096ab6052..e09b00c86 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -1,15 +1,15 @@ import type * as plusSuggestions from "~/db/models/plusSuggestions.server"; import { monthsVotingRange } from "./core/plus"; -import type { PlusSuggestion, User } from "./db/types"; +import type { PlusSuggestion, User, UserWithPlusTier } from "./db/types"; import { allTruthy } from "./utils/arrays"; // TODO: 1) move "root checkers" to one file and utils to one file 2) make utils const for more terseness interface CanAddCommentToSuggestionArgs { - user?: Pick; + user?: Pick; suggestions: plusSuggestions.FindVisibleForUser; suggested: Pick; - targetPlusTier: NonNullable; + targetPlusTier: NonNullable; } export function canAddCommentToSuggestionFE( args: CanAddCommentToSuggestionArgs @@ -129,7 +129,7 @@ function suggestionHasNoOtherComments({ } interface CanSuggestNewUserFEArgs { - user?: Pick; + user?: Pick; suggestions: plusSuggestions.FindVisibleForUser; } export function canSuggestNewUserFE({ @@ -147,8 +147,8 @@ export function canSuggestNewUserFE({ } interface CanSuggestNewUserBEArgs extends CanSuggestNewUserFEArgs { - suggested: Pick; - targetPlusTier: NonNullable; + suggested: Pick; + targetPlusTier: NonNullable; } export function canSuggestNewUserBE({ user, @@ -176,7 +176,7 @@ function isVotingActive() { ); } -function isPlusServerMember(user?: Pick) { +function isPlusServerMember(user?: Pick) { return Boolean(user?.plusTier); } diff --git a/app/root.tsx b/app/root.tsx index ab9dc8815..68b6321d7 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -19,7 +19,7 @@ import globalStyles from "~/styles/global.css"; import layoutStyles from "~/styles/layout.css"; import resetStyles from "~/styles/reset.css"; import { Layout } from "./components/layout"; -import type { User } from "./db/types"; +import type { UserWithPlusTier } from "./db/types"; import { getUser } from "./utils/remix"; export const unstable_shouldReload: ShouldReloadFunction = () => false; @@ -40,7 +40,10 @@ export const meta: MetaFunction = () => ({ }); export interface RootLoaderData { - user?: Pick; + user?: Pick< + UserWithPlusTier, + "id" | "discordId" | "discordAvatar" | "plusTier" + >; } export const loader: LoaderFunction = async ({ request }) => { diff --git a/app/routes/plus/suggestions/new.tsx b/app/routes/plus/suggestions/new.tsx index f3366dfc1..6d39dbd0e 100644 --- a/app/routes/plus/suggestions/new.tsx +++ b/app/routes/plus/suggestions/new.tsx @@ -27,7 +27,7 @@ import { } from "~/utils/remix"; import { upcomingVoting } from "~/core/plus"; import { db } from "~/db"; -import type { User } from "~/db/types"; +import type { UserWithPlusTier } from "~/db/types"; import { ErrorMessage } from "~/components/ErrorMessage"; const commentActionSchema = z.object({ @@ -169,7 +169,7 @@ function getSelectedUserErrorMessage({ }: { suggestions: NonNullable; targetPlusTier: number; - suggested?: Pick; + suggested?: Pick; }) { if (!suggested) return; diff --git a/app/routes/users.tsx b/app/routes/users.tsx index 471f4be11..ca2445753 100644 --- a/app/routes/users.tsx +++ b/app/routes/users.tsx @@ -1,13 +1,13 @@ import type { LoaderFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { db } from "~/db"; -import type { User } from "~/db/types"; +import type { UserWithPlusTier } from "~/db/types"; import { discordFullName } from "~/utils/strings"; export interface UsersLoaderData { users: ({ discordFullName: string; - } & Pick)[]; + } & Pick)[]; } export const loader: LoaderFunction = () => { diff --git a/app/utils/dates.ts b/app/utils/dates.ts index 4e4fb4cb4..46afbbfd0 100644 --- a/app/utils/dates.ts +++ b/app/utils/dates.ts @@ -1,3 +1,7 @@ export function databaseTimestampToDate(timestamp: number) { return new Date(timestamp * 1000); } + +export function dateToDatabaseTimestamp(date: Date) { + return Math.floor(date.getTime() / 1000); +} diff --git a/migrations/000-initial.sql b/migrations/000-initial.sql index 822892b19..dda459c59 100644 --- a/migrations/000-initial.sql +++ b/migrations/000-initial.sql @@ -8,8 +8,7 @@ CREATE TABLE "User" ( "twitter" text, "youtubeId" text, "bio" text, - "country" text, - "plusTier" integer + "country" text ) STRICT; --- @@ -24,7 +23,13 @@ CREATE TABLE "PlusSuggestion" ( "createdAt" integer DEFAULT (strftime('%s', 'now')) NOT NULL, FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE, FOREIGN KEY ("suggestedId") REFERENCES "User"("id") ON DELETE CASCADE, - UNIQUE("month", "year", "suggestedId", "authorId", "tier") ON CONFLICT ROLLBACK + UNIQUE( + "month", + "year", + "suggestedId", + "authorId", + "tier" + ) ON CONFLICT ROLLBACK ) STRICT; CREATE INDEX plus_suggestion_author_id ON "PlusSuggestion"("authorId"); @@ -39,9 +44,61 @@ CREATE TABLE "PlusVote" ( "authorId" integer NOT NULL, "votedId" integer NOT NULL, "score" integer NOT NULL, + "validAfter" integer NOT NULL, UNIQUE("month", "year", "authorId", "votedId") ON CONFLICT ROLLBACK ) STRICT; CREATE INDEX plus_vote_author_id ON "PlusVote"("authorId"); -CREATE INDEX plus_vote_voted_id ON "PlusVote"("votedId"); \ No newline at end of file +CREATE INDEX plus_vote_voted_id ON "PlusVote"("votedId"); + +-- 1) Get the latest finished month/year +-- 2) Get votes that match this finished month/year then get vote average per user+tier +-- 3) Final result is userId + lowest tier with average of 50 or greater +CREATE VIEW "PlusTier" AS WITH "LastFinishedVotingMonthsAverages" AS ( + SELECT + votedId, + tier, + AVG(score) AS average + FROM + PlusVote + WHERE + year = ( + SELECT + year + FROM + PlusVote + WHERE + validAfter < strftime('%s', 'now') + ORDER BY + year desc, + month desc + LIMIT + 1 + ) + AND month = ( + SELECT + month + FROM + PlusVote + WHERE + validAfter < strftime('%s', 'now') + ORDER BY + year desc, + month desc + LIMIT + 1 + ) + GROUP BY + "votedId", + tier +) +SELECT + "votedId" as "userId", + min(tier) AS tier +FROM + "LastFinishedVotingMonthsAverages" +WHERE + average >= 0.5 +GROUP BY + "votedId"; \ No newline at end of file diff --git a/package.json b/package.json index 733fd7951..51afe379f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev:cypress": "cross-env NODE_ENV=test PORT=4455 remix dev", "start": "remix-serve build", "migrate": "node ./migrations/index.mjs", - "migrate:reset": "node scripts/delete-db-files.mjs && npm run migrate", + "migrate:reset": "node scripts/delete-db-files.mjs && npm run migrate && npm run seed", "seed": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/seed.ts", "seed:cypress": "cross-env NODE_ENV=test npm run seed", "lint:ts": "eslint . --ext .ts,.tsx", diff --git a/scripts/seed.ts b/scripts/seed.ts index fd29f8d98..926014070 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,93 +1,6 @@ -import { db } from "~/db"; -import { sql } from "~/db/sql"; -import { faker } from "@faker-js/faker"; -import type { User } from "~/db/types"; -import invariant from "tiny-invariant"; -import { upcomingVoting } from "~/core/plus"; - -const ADMIN_TEST_DISCORD_ID = "79237403620945920"; -const ADMIN_TEST_AVATAR = "fcfd65a3bea598905abb9ca25296816b"; - -const basicSeeds = [adminUser, users, plusTierToUsers, plusSuggestions]; - -export function seed() { - wipeDB(); - // eslint-disable-next-line no-console - console.log("database wiped..."); - for (const seedFunc of basicSeeds) { - seedFunc(); - } - - // eslint-disable-next-line no-console - console.log("seeded!"); -} - -function wipeDB() { - const tablesToDelete = ["User"]; - - for (const table of tablesToDelete) { - sql.prepare(`delete from "${table}"`).run(); - } -} - -function adminUser() { - db.users.upsert({ - discordDiscriminator: "4059", - discordId: ADMIN_TEST_DISCORD_ID, - discordName: "Sendou", - twitch: "Sendou", - youtubeId: "UCWbJLXByvsfQvTcR4HLPs5Q", - discordAvatar: ADMIN_TEST_AVATAR, - twitter: "sendouc", - }); -} - -function users() { - new Array(500).fill(null).map(fakeUser).forEach(db.users.upsert); -} - -function fakeUser() { - return { - discordAvatar: null, - discordDiscriminator: String(faker.random.numeric(4)), - discordId: String(faker.random.numeric(17)), - discordName: faker.random.word(), - twitch: null, - twitter: null, - youtubeId: null, - }; -} - -function plusTierToUsers() { - sql.prepare(`update "USER" set "plusTier" = 3 where id < 150`).run(); - sql.prepare(`update "USER" set "plusTier" = 2 where id < 80`).run(); - sql.prepare(`update "USER" set "plusTier" = 1 where id < 30`).run(); -} - -function plusSuggestions() { - const usersInPlus = sql - .prepare(`select * from "User" where "plusTier" is not null`) - .all() as User[]; - const { month, year } = upcomingVoting(new Date()); - - for (let userId = 150; userId < 190; userId++) { - const amountOfSuggestions = faker.helpers.arrayElement([1, 1, 2, 3, 4]); - - for (let i = 0; i < amountOfSuggestions; i++) { - const suggester = usersInPlus.shift(); - invariant(suggester); - invariant(suggester.plusTier); - - db.plusSuggestions.create({ - authorId: suggester.id, - month, - year, - suggestedId: userId, - text: faker.lorem.lines(), - tier: suggester.plusTier, - }); - } - } -} +/* eslint-disable no-console */ +import { seed } from "~/db/seed"; +console.log("seeding..."); seed(); +console.log("done!");