diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index 1d3cf3b07..fdfbd313b 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -33,7 +33,7 @@ export function Combobox>({ if (!query) return []; const fuse = new Fuse(options, { - keys: [...Object.keys(options[0])], + keys: [...Object.keys(options[0] ?? {})], }); return fuse .search(query) diff --git a/app/components/Dialog.tsx b/app/components/Dialog.tsx index b782c3c12..4c8bccfb3 100644 --- a/app/components/Dialog.tsx +++ b/app/components/Dialog.tsx @@ -1,4 +1,5 @@ import React from "react"; +import invariant from "tiny-invariant"; export function Dialog({ children, @@ -46,6 +47,7 @@ function useDOMSync(isOpen: boolean) { if (!dialog.open && !isOpen) return; const html = document.getElementsByTagName("html")[0]; + invariant(html); if (isOpen) { dialog.showModal(); diff --git a/app/components/Label.tsx b/app/components/Label.tsx index 848224040..dcac385ca 100644 --- a/app/components/Label.tsx +++ b/app/components/Label.tsx @@ -29,4 +29,6 @@ export function Label({ valueLimits, children, htmlFor }: LabelProps) { function lengthWarning(valueLimits: NonNullable) { if (valueLimits.current >= valueLimits.max) return "error"; if (valueLimits.current / valueLimits.max >= 0.9) return "warning"; + + return; } diff --git a/app/components/Popover.tsx b/app/components/Popover.tsx index aaf2e31b7..eab52e8f4 100644 --- a/app/components/Popover.tsx +++ b/app/components/Popover.tsx @@ -28,8 +28,8 @@ export function Popover({ // @ts-expect-error Popper docs: https://popper.js.org/react-popper/v2/ ref={setPopperElement} className="popover-content" - style={styles.popper} - {...attributes.popper} + style={styles["popper"]} + {...attributes["popper"]} > {children} diff --git a/app/components/layout/DrawingSection.tsx b/app/components/layout/DrawingSection.tsx index 6f06906f3..2ff6168c3 100644 --- a/app/components/layout/DrawingSection.tsx +++ b/app/components/layout/DrawingSection.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import randomColor from "randomcolor"; import { useState } from "react"; +import { atOrError } from "~/utils/arrays"; interface HSL { h: number; @@ -92,13 +93,19 @@ class Color { multiply(matrix: number[]) { const newR = this.clamp( - this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2] + this.r * atOrError(matrix, 0) + + this.g * atOrError(matrix, 1) + + this.b * atOrError(matrix, 2) ); const newG = this.clamp( - this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5] + this.r * atOrError(matrix, 3) + + this.g * atOrError(matrix, 4) + + this.b * atOrError(matrix, 5) ); const newB = this.clamp( - this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8] + this.r * atOrError(matrix, 6) + + this.g * atOrError(matrix, 7) + + this.b * atOrError(matrix, 8) ); this.r = newR; this.g = newG; @@ -240,15 +247,15 @@ class Solver { const ck = c / Math.pow(k + 1, gamma); for (let i = 0; i < 6; i++) { deltas[i] = Math.random() > 0.5 ? 1 : -1; - highArgs[i] = values[i] + ck * deltas[i]; - lowArgs[i] = values[i] - ck * deltas[i]; + highArgs[i] = atOrError(values, i) + ck * deltas[i]; + lowArgs[i] = atOrError(values, i) - ck * deltas[i]; } const lossDiff = this.loss(highArgs) - this.loss(lowArgs); for (let i = 0; i < 6; i++) { const g = (lossDiff / (2 * ck)) * deltas[i]; - const ak = a[i] / Math.pow(A + k + 1, alpha); - values[i] = fix(values[i] - ak * g, i); + const ak = atOrError(a, i) / Math.pow(A + k + 1, alpha); + values[i] = fix(atOrError(values, i) - ak * g, i); } const loss = this.loss(values); @@ -287,12 +294,12 @@ class Solver { const color = this.reusedColor; color.set(0, 0, 0); - color.invert(filters[0] / 100); - color.sepia(filters[1] / 100); - color.saturate(filters[2] / 100); - color.hueRotate(filters[3] * 3.6); - color.brightness(filters[4] / 100); - color.contrast(filters[5] / 100); + color.invert(atOrError(filters, 0) / 100); + color.sepia(atOrError(filters, 1) / 100); + color.saturate(atOrError(filters, 2) / 100); + color.hueRotate(atOrError(filters, 3) * 3.6); + color.brightness(atOrError(filters, 4) / 100); + color.contrast(atOrError(filters, 5) / 100); const colorHSL = color.hsl(); return ( @@ -307,7 +314,7 @@ class Solver { css(filters: number[]) { function fmt(idx: number, multiplier = 1) { - return Math.round(filters[idx] * multiplier); + return Math.round(atOrError(filters, idx) * multiplier); } return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt( 2 @@ -328,9 +335,9 @@ function hexToRgb(hex: string): RGB { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (result) { return [ - parseInt(result[1], 16), - parseInt(result[2], 16), - parseInt(result[3], 16), + parseInt(atOrError(result, 1), 16), + parseInt(atOrError(result, 2), 16), + parseInt(atOrError(result, 3), 16), ]; } @@ -340,7 +347,11 @@ function hexToRgb(hex: string): RGB { function getFilters(hex: string) { let rgb = [255, 255, 255]; rgb = hexToRgb(hex); - const color = new Color(rgb[0], rgb[1], rgb[2]); + const color = new Color( + atOrError(rgb, 0), + atOrError(rgb, 1), + atOrError(rgb, 2) + ); const solver = new Solver(color); const result = solver.solve(); return result.filter; diff --git a/app/core/auth/DiscordStrategy.server.ts b/app/core/auth/DiscordStrategy.server.ts index b9da7749f..76386298d 100644 --- a/app/core/auth/DiscordStrategy.server.ts +++ b/app/core/auth/DiscordStrategy.server.ts @@ -41,17 +41,20 @@ export class DiscordStrategy extends OAuth2Strategy< scope: string; constructor() { - invariant(process.env.DISCORD_CLIENT_ID); - invariant(process.env.DISCORD_CLIENT_SECRET); - invariant(process.env.BASE_URL); + invariant(process.env["DISCORD_CLIENT_ID"]); + invariant(process.env["DISCORD_CLIENT_SECRET"]); + invariant(process.env["BASE_URL"]); super( { authorizationURL: "https://discord.com/api/oauth2/authorize", tokenURL: "https://discord.com/api/oauth2/token", - clientID: process.env.DISCORD_CLIENT_ID, - clientSecret: process.env.DISCORD_CLIENT_SECRET, - callbackURL: new URL("/auth/callback", process.env.BASE_URL).toString(), + clientID: process.env["DISCORD_CLIENT_ID"], + clientSecret: process.env["DISCORD_CLIENT_SECRET"], + callbackURL: new URL( + "/auth/callback", + process.env["BASE_URL"] + ).toString(), }, async ({ accessToken }) => { const authHeader = ["Authorization", `Bearer ${accessToken}`]; diff --git a/app/core/auth/session.server.ts b/app/core/auth/session.server.ts index e5449b706..978801d12 100644 --- a/app/core/auth/session.server.ts +++ b/app/core/auth/session.server.ts @@ -1,14 +1,14 @@ import { createCookieSessionStorage } from "@remix-run/node"; import invariant from "tiny-invariant"; -invariant(process.env.SESSION_SECRET); +invariant(process.env["SESSION_SECRET"]); export const sessionStorage = createCookieSessionStorage({ cookie: { name: "_session", sameSite: "lax", path: "/", httpOnly: true, - secrets: [process.env.SESSION_SECRET], + secrets: [process.env["SESSION_SECRET"]], secure: process.env.NODE_ENV === "production", }, }); diff --git a/app/db/models/plusSuggestions.server.ts b/app/db/models/plusSuggestions.server.ts index df0be4027..471abf3d9 100644 --- a/app/db/models/plusSuggestions.server.ts +++ b/app/db/models/plusSuggestions.server.ts @@ -1,4 +1,5 @@ import type { MonthYear } from "~/core/plus"; +import { atOrError } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; import { sql } from "../sql"; import type { PlusSuggestion, User, UserWithPlusTier } from "../types"; @@ -68,7 +69,7 @@ export interface FindVisibleForUserSuggestedUserInfo { | "discordDiscriminator" | "discordAvatar" >; - suggestions: (Pick & { + suggestions: (Pick & { createdAtText: string; author: Pick< User, @@ -91,10 +92,12 @@ export function findVisibleForUser( function mapFindVisibleForUserRowsToResult(rows: any[]): FindVisibleForUser { return rows.reduce((result: FindVisibleForUser, row) => { - if (!result[row.tier]) result[row.tier] = []; + const usersOfTier = result[row.tier] ?? []; + result[row.tier] = usersOfTier; const suggestionInfo = { id: row.id, + createdAt: row.createdAt, createdAtText: databaseTimestampToDate(row.createdAt).toLocaleString( "en-US", { @@ -113,14 +116,14 @@ function mapFindVisibleForUserRowsToResult(rows: any[]): FindVisibleForUser { }, }; - const existingSuggestion = result[row.tier].find( + const existingSuggestion = usersOfTier.find( (suggestion) => suggestion.suggestedUser.id === row.suggestedId ); if (existingSuggestion) { existingSuggestion.suggestions.push(suggestionInfo); } else { - result[row.tier].push({ + usersOfTier.push({ suggestedUser: { id: row.suggestedId, discordId: row.suggestedDiscordId, @@ -143,7 +146,9 @@ function sortNewestPlayersToBeSuggestedFirst( Object.entries(suggestions).map(([tier, suggestions]) => [ tier, suggestions.sort( - (a, b) => b.suggestions[0].createdAt - a.suggestions[0].createdAt + (a, b) => + atOrError(b.suggestions, 0).createdAt - + atOrError(a.suggestions, 0).createdAt ), ]) ); diff --git a/app/db/models/plusVotes.server.ts b/app/db/models/plusVotes.server.ts index 27e1ab342..da3d03171 100644 --- a/app/db/models/plusVotes.server.ts +++ b/app/db/models/plusVotes.server.ts @@ -98,14 +98,13 @@ function groupPlusVotingResults( > = {}; for (const row of rows) { - if (!grouped[row.tier]) { - grouped[row.tier] = { - passed: [], - failed: [], - }; - } + const playersOfTier = grouped[row.tier] ?? { + passed: [], + failed: [], + }; + grouped[row.tier] = playersOfTier; - grouped[row.tier][row.passedVoting ? "passed" : "failed"].push({ + playersOfTier[row.passedVoting ? "passed" : "failed"].push({ id: row.id, discordAvatar: row.discordAvatar, discordDiscriminator: row.discordDiscriminator, diff --git a/app/db/sql.ts b/app/db/sql.ts index 5b0dda806..1584cf062 100644 --- a/app/db/sql.ts +++ b/app/db/sql.ts @@ -1,8 +1,8 @@ import Database from "better-sqlite3"; import invariant from "tiny-invariant"; -invariant(process.env.DB_PATH, "DB_PATH env variable must be set"); -export const sql = new Database(process.env.DB_PATH); +invariant(process.env["DB_PATH"], "DB_PATH env variable must be set"); +export const sql = new Database(process.env["DB_PATH"]); sql.pragma("journal_mode = WAL"); sql.pragma("foreign_keys = ON"); diff --git a/app/hooks/useUser.ts b/app/hooks/useUser.ts index 8ebd7d9e5..e4c5ef69a 100644 --- a/app/hooks/useUser.ts +++ b/app/hooks/useUser.ts @@ -1,8 +1,10 @@ import { useMatches } from "@remix-run/react"; +import invariant from "tiny-invariant"; import type { RootLoaderData } from "~/root"; export const useUser = () => { const [root] = useMatches(); + invariant(root); return (root.data as RootLoaderData).user; }; diff --git a/app/permissions.ts b/app/permissions.ts index fa20ae7b1..d46367f9f 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -193,7 +193,10 @@ function hasUserSuggestedThisMonth({ }: Pick) { return Object.values(suggestions) .flat() - .some(({ suggestions }) => suggestions[0].author.id === user?.id); + .some( + ({ suggestions }) => + suggestions[0] && suggestions[0].author.id === user?.id + ); } export function canVoteFE() { diff --git a/app/routes/plus/suggestions.tsx b/app/routes/plus/suggestions.tsx index baee8db5b..dd810d330 100644 --- a/app/routes/plus/suggestions.tsx +++ b/app/routes/plus/suggestions.tsx @@ -124,6 +124,7 @@ export default function PlusSuggestionsPage() { tierVisible && data.suggestions[tierVisible] ? data.suggestions[tierVisible] : []; + invariant(visibleSuggestions); // xxx: looks strange when suggestedforinfo and suggestions both show https://cdn.discordapp.com/attachments/816458257714511872/984195125913731102/unknown.png return ( diff --git a/app/routes/plus/suggestions/comment.$tier.$userId.tsx b/app/routes/plus/suggestions/comment.$tier.$userId.tsx index 529179c7b..4dd3a0029 100644 --- a/app/routes/plus/suggestions/comment.$tier.$userId.tsx +++ b/app/routes/plus/suggestions/comment.$tier.$userId.tsx @@ -63,8 +63,8 @@ export default function PlusCommentModalPage() { const params = useParams(); const data = atOrError(matches, -2).data as PlusSuggestionsLoaderData; - const targetUserId = Number(params.userId); - const tierSuggestedTo = String(params.tier); + const targetUserId = Number(params["userId"]); + const tierSuggestedTo = String(params["tier"]); const userBeingCommented = data.suggestions?.[tierSuggestedTo]?.find( (u) => u.suggestedUser.id === targetUserId diff --git a/app/routes/plus/suggestions/new.tsx b/app/routes/plus/suggestions/new.tsx index adc079ffe..7abeda24d 100644 --- a/app/routes/plus/suggestions/new.tsx +++ b/app/routes/plus/suggestions/new.tsx @@ -32,6 +32,7 @@ import { upcomingVoting } from "~/core/plus"; import { db } from "~/db"; import type { UserWithPlusTier } from "~/db/types"; import { ErrorMessage } from "~/components/ErrorMessage"; +import { atOrError } from "~/utils/arrays"; const commentActionSchema = z.object({ tier: z.preprocess(actualNumber, z.number().min(1).max(3)), @@ -80,7 +81,7 @@ export const action: ActionFunction = async ({ request }) => { export default function PlusNewSuggestionModalPage() { const user = useUser(); const matches = useMatches(); - const data = matches.at(-2)!.data as PlusSuggestionsLoaderData; + const data = atOrError(matches, -2).data as PlusSuggestionsLoaderData; const [selectedUser, setSelectedUser] = React.useState<{ /** User id */ value: string; @@ -93,16 +94,17 @@ export default function PlusNewSuggestionModalPage() { return tier >= user.plusTier; }); - const [targetPlusTier, setTargetPlusTier] = React.useState( - tierOptions[0] - ); + const [targetPlusTier, setTargetPlusTier] = React.useState< + number | undefined + >(tierOptions[0]); if ( !data.suggestions || !canSuggestNewUserFE({ user, suggestions: data.suggestions, - }) + }) || + !targetPlusTier ) { return ; } @@ -187,6 +189,8 @@ function getSelectedUserErrorMessage({ if (playerAlreadySuggested({ targetPlusTier, suggestions, suggested })) { return `This user was already suggested to +${targetPlusTier}`; } + + return; } // TODO: better UX - allow going over but prevent submit like Twitter diff --git a/app/routes/u.$identifier/edit.tsx b/app/routes/u.$identifier/edit.tsx index e89758573..b59aede84 100644 --- a/app/routes/u.$identifier/edit.tsx +++ b/app/routes/u.$identifier/edit.tsx @@ -2,6 +2,7 @@ import type { ActionFunction, LinksFunction } from "@remix-run/node"; import { Form, useMatches, useTransition } from "@remix-run/react"; import { countries } from "countries-list"; import * as React from "react"; +import invariant from "tiny-invariant"; import { z } from "zod"; import { Button } from "~/components/Button"; import { Label } from "~/components/Label"; @@ -47,6 +48,7 @@ export const action: ActionFunction = async ({ request }) => { export default function UserEditPage() { const [, parentRoute] = useMatches(); + invariant(parentRoute); const data = parentRoute.data as UserPageLoaderData; const transition = useTransition(); diff --git a/app/routes/u.$identifier/index.tsx b/app/routes/u.$identifier/index.tsx index 87bb05386..23adb81d2 100644 --- a/app/routes/u.$identifier/index.tsx +++ b/app/routes/u.$identifier/index.tsx @@ -1,5 +1,6 @@ import type { LinksFunction } from "@remix-run/node"; import { useMatches } from "@remix-run/react"; +import invariant from "tiny-invariant"; import { Avatar } from "~/components/Avatar"; import { SocialLink } from "~/components/u/SocialLink"; import styles from "~/styles/u.css"; @@ -11,6 +12,7 @@ export const links: LinksFunction = () => { export default function UserInfoPage() { const [, parentRoute] = useMatches(); + invariant(parentRoute); const data = parentRoute.data as UserPageLoaderData; return ( diff --git a/tsconfig.json b/tsconfig.json index 82d7b38c9..bd9ef0f63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,13 @@ }, "noEmit": true, "forceConsistentCasingInFileNames": true, - "allowJs": false + "allowJs": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "useUnknownInCatchVariables": true } }