PlusTier view

This commit is contained in:
Kalle
2022-06-04 16:04:43 +03:00
parent 217cb09ee2
commit 7528652b42
16 changed files with 205 additions and 154 deletions

View File

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

View File

@@ -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<Pick<User, "discordId" | "plusTier">>,
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
"inputName" | "onChange"
>) {
const fetcher = useFetcher<UsersLoaderData>();

View File

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

View File

@@ -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<User, "plusTier">
args: MonthYear & Pick<UserWithPlusTier, "plusTier">
): FindVisibleForUser | undefined {
if (!args.plusTier) return;
return sortNewestPlayersToBeSuggestedFirst(

View File

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

View File

@@ -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<User, "country" | "id" | "bio">) {
}
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"
>[];
}

View File

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

View File

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

View File

@@ -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, "id" | "plusTier">;
user?: Pick<UserWithPlusTier, "id" | "plusTier">;
suggestions: plusSuggestions.FindVisibleForUser;
suggested: Pick<User, "id">;
targetPlusTier: NonNullable<User["plusTier"]>;
targetPlusTier: NonNullable<UserWithPlusTier["plusTier"]>;
}
export function canAddCommentToSuggestionFE(
args: CanAddCommentToSuggestionArgs
@@ -129,7 +129,7 @@ function suggestionHasNoOtherComments({
}
interface CanSuggestNewUserFEArgs {
user?: Pick<User, "id" | "plusTier">;
user?: Pick<UserWithPlusTier, "id" | "plusTier">;
suggestions: plusSuggestions.FindVisibleForUser;
}
export function canSuggestNewUserFE({
@@ -147,8 +147,8 @@ export function canSuggestNewUserFE({
}
interface CanSuggestNewUserBEArgs extends CanSuggestNewUserFEArgs {
suggested: Pick<User, "id" | "plusTier">;
targetPlusTier: NonNullable<User["plusTier"]>;
suggested: Pick<UserWithPlusTier, "id" | "plusTier">;
targetPlusTier: NonNullable<UserWithPlusTier["plusTier"]>;
}
export function canSuggestNewUserBE({
user,
@@ -176,7 +176,7 @@ function isVotingActive() {
);
}
function isPlusServerMember(user?: Pick<User, "plusTier">) {
function isPlusServerMember(user?: Pick<UserWithPlusTier, "plusTier">) {
return Boolean(user?.plusTier);
}

View File

@@ -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, "id" | "discordId" | "discordAvatar" | "plusTier">;
user?: Pick<
UserWithPlusTier,
"id" | "discordId" | "discordAvatar" | "plusTier"
>;
}
export const loader: LoaderFunction = async ({ request }) => {

View File

@@ -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<PlusSuggestionsLoaderData["suggestions"]>;
targetPlusTier: number;
suggested?: Pick<User, "id" | "plusTier">;
suggested?: Pick<UserWithPlusTier, "id" | "plusTier">;
}) {
if (!suggested) return;

View File

@@ -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<User, "id" | "discordId" | "plusTier">)[];
} & Pick<UserWithPlusTier, "id" | "discordId" | "plusTier">)[];
}
export const loader: LoaderFunction = () => {

View File

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

View File

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

View File

@@ -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",

View File

@@ -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!");