Merge remote-tracking branch 'origin/main' into tourney-custom-themes

This commit is contained in:
hfcRed
2026-08-05 18:45:26 -04:00
293 changed files with 6040 additions and 3637 deletions

View File

@@ -38,6 +38,7 @@
- one file can have many components
- all texts should be provided translations via the i18next library's `useTranslations` hook's `t` function
- instead of `&&` operator for conditional rendering, use the ternary operator
- fixed-field mutations (an `_action` plus hidden inputs) use `<ActionButton>` which type checks the action and fields against the route's zod action schema; real multi-input forms instead pass `schema` alongside `_action` to `SubmitButton`; enforced by the `no-raw-action-forms` Biome plugin
- for localized user-readable time strings use `<LocaleTime />`, `<LocaleTimeRange>` or `useFormatDistanceToNow`. If needed use `useDateTimeFormat` directly. NEVER use e.g. `toLocaleString` directly as it does not include users' language selection.
## Remix/React Router

View File

@@ -0,0 +1,113 @@
import type * as React from "react";
import { type FetcherWithComponents, useFetcher } from "react-router";
import type { z } from "zod";
import {
type ActionsOf,
type FieldsOf,
serializeFieldValue,
} from "~/utils/action-schemas";
import { SendouButton, type SendouButtonProps } from "./elements/Button";
import { FormWithConfirm } from "./FormWithConfirm";
import { SubmitButton } from "./SubmitButton";
interface ActionButtonBaseProps<
TSchema extends z.ZodTypeAny,
TAction extends ActionsOf<TSchema>,
> extends Omit<SendouButtonProps, "type" | "name" | "value" | "form"> {
/** Action schema of the route the button submits to. Only used for typing `action` and `fields`. */
schema: TSchema;
/** `_action` to submit, narrowed to the literals of the schema. */
action: TAction;
/** Route to submit to. Defaults to the current route. */
formAction?: string;
formClassName?: string;
/** Fetcher to submit with, e.g. to share submitting state between buttons. Defaults to own fetcher. */
fetcher?: FetcherWithComponents<unknown>;
/** When set, submits only after the user confirms via a dialog. */
confirm?: {
dialogHeading: string;
description?: React.ReactNode;
submitButtonText?: string;
submitButtonVariant?: SendouButtonProps["variant"];
};
}
type ActionButtonProps<
TSchema extends z.ZodTypeAny,
TAction extends ActionsOf<TSchema>,
> = ActionButtonBaseProps<TSchema, TAction> &
// biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields"
({} extends FieldsOf<TSchema, TAction>
? { fields?: FieldsOf<TSchema, TAction> }
: { fields: FieldsOf<TSchema, TAction> });
/**
* Button that submits a mutation to a route action as `_action` + hidden fields,
* type checked against the route's zod action schema.
*
* @example
* <ActionButton
* schema={lookingSchema}
* action="LIKE"
* fields={{ targetGroupId: group.id }}
* >
* {t("q:looking.groups.actions.invite")}
* </ActionButton>
*/
export function ActionButton<
TSchema extends z.ZodTypeAny,
const TAction extends ActionsOf<TSchema>,
>({
schema,
action,
fields,
formAction,
formClassName,
fetcher: fetcherFromProps,
confirm,
children,
...buttonProps
}: ActionButtonProps<TSchema, TAction>) {
const ownFetcher = useFetcher();
const fetcher = fetcherFromProps ?? ownFetcher;
const fieldEntries = Object.entries(
(fields ?? {}) as Record<string, unknown>,
).flatMap(([name, value]) =>
value === undefined || value === null
? []
: ([[name, serializeFieldValue(value)]] as const),
);
if (confirm) {
return (
<FormWithConfirm
dialogHeading={confirm.dialogHeading}
description={confirm.description}
submitButtonText={confirm.submitButtonText}
submitButtonVariant={confirm.submitButtonVariant}
action={formAction}
fetcher={fetcherFromProps}
fields={[["_action", action], ...fieldEntries]}
>
<SendouButton {...buttonProps}>{children}</SendouButton>
</FormWithConfirm>
);
}
return (
<fetcher.Form method="post" action={formAction} className={formClassName}>
{fieldEntries.map(([name, value]) => (
<input type="hidden" key={name} name={name} value={value} />
))}
<SubmitButton
schema={schema}
_action={action}
state={fetcher.state}
{...buttonProps}
>
{children}
</SubmitButton>
</fetcher.Form>
);
}

View File

@@ -0,0 +1,48 @@
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { RelativeTime } from "./RelativeTime";
const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
timeZoneName: "short",
};
function renderRelativeTime(timestamp: number) {
const router = createMemoryRouter(
[
{
path: "/",
element: <RelativeTime timestamp={timestamp}>3 days ago</RelativeTime>,
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
function expectedTitle(timestamp: number) {
const language =
navigator.languages.find(
(lang) => lang.split("-")[0].toLowerCase() === "en",
) ?? "en";
return new Intl.DateTimeFormat(language, FORMAT_OPTIONS).format(timestamp);
}
describe("RelativeTime", () => {
test("tooltip shows the date the millisecond timestamp points to", async () => {
const timestamp = new Date("2025-08-02T12:00:00Z").getTime();
const screen = await renderRelativeTime(timestamp);
const abbr = screen.getByText("3 days ago");
await expect.element(abbr).toBeVisible();
expect(abbr.element().getAttribute("title")).toBe(expectedTitle(timestamp));
});
});

View File

@@ -17,6 +17,8 @@ export function RelativeTime({
});
return (
<abbr title={formatter.format(timestamp) ?? undefined}>{children}</abbr>
<abbr title={formatter.format(new Date(timestamp)) ?? undefined}>
{children}
</abbr>
);
}

View File

@@ -163,6 +163,7 @@ function SaveTournamentStreamButton({
>
<input
type="hidden"
// biome-ignore lint/plugin: bare icon button that also posts revalidateRoot for the root loader
name="_action"
value={optimisticSaved ? "UNSAVE_TOURNAMENT" : "SAVE_TOURNAMENT"}
/>

View File

@@ -1,20 +1,30 @@
import { type FetcherWithComponents, useNavigation } from "react-router";
import type { z } from "zod";
import type { ActionsOf } from "~/utils/action-schemas";
import { SendouButton, type SendouButtonProps } from "./elements/Button";
interface SubmitButtonProps extends SendouButtonProps {
type SubmitButtonProps<TSchema extends z.ZodTypeAny> = SendouButtonProps & {
/** If the page has multiple forms you can pass in fetcher.state to differentiate when this SubmitButton should be in submitting state */
state?: FetcherWithComponents<any>["state"];
_action?: string;
testId?: string;
}
} & (
| {
/** Action schema of the route the form submits to. Only used for typing `_action`. */
schema: TSchema;
/** `_action` to submit, narrowed to the literals of the schema. */
_action: ActionsOf<TSchema>;
}
| { schema?: never; _action?: never }
);
export function SubmitButton({
export function SubmitButton<TSchema extends z.ZodTypeAny>({
children,
state,
schema: _schema,
_action,
testId,
...rest
}: SubmitButtonProps) {
}: SubmitButtonProps<TSchema>) {
const navigation = useNavigation();
const isSubmitting = state ? state !== "idle" : navigation.state !== "idle";

View File

@@ -0,0 +1,10 @@
.userLink {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
color: var(--color-text);
&.vertical {
flex-direction: column;
}
}

View File

@@ -0,0 +1,53 @@
import clsx from "clsx";
import type * as React from "react";
import { Link } from "react-router";
import type { Tables } from "~/db/tables";
import { userPage } from "~/utils/urls";
import { Avatar } from "./Avatar";
import styles from "./UserLink.module.css";
type UserLinkUser = Pick<
Tables["User"],
"username" | "discordId" | "discordAvatar"
> & {
customUrl?: Tables["User"]["customUrl"];
customAvatarUrl?: string | null;
};
type UnlinkedPlayer = { name: string | null } & {
[K in keyof UserLinkUser]: UserLinkUser[K] | null;
};
/** Link to a user's page showing their avatar and username. Accepts also result players without an account, rendering just their name. */
export function UserLink({
user,
size = "xxs",
direction = "horizontal",
className,
children,
}: {
user: UserLinkUser | UnlinkedPlayer;
size?: React.ComponentProps<typeof Avatar>["size"];
direction?: "horizontal" | "vertical";
className?: string;
/** Replaces the plain username text (avatar is always rendered) */
children?: React.ReactNode;
}) {
if (user.username === null) {
return <>{(user as UnlinkedPlayer).name}</>;
}
const linkedUser = user as UserLinkUser;
return (
<Link
to={userPage(linkedUser)}
className={clsx(styles.userLink, className, {
[styles.vertical]: direction === "vertical",
})}
>
<Avatar user={linkedUser} size={size} />
{children ?? linkedUser.username}
</Link>
);
}

View File

@@ -24,6 +24,7 @@ import { searchSearchParams } from "~/features/search/search-search-params";
import { useDebounce } from "~/hooks/useDebounce";
import { useHydrated } from "~/hooks/useHydrated";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import {
navIconUrl,
@@ -33,6 +34,11 @@ import {
weaponCategoryUrl,
} from "~/utils/urls";
import styles from "./GlobalSearch.module.css";
import {
saveRecentWeapon,
searchTypePersisted,
useRecentWeapons,
} from "./global-search-persisted";
import {
globalSearchSearchParams,
GLOBAL_SEARCH_TYPES as SEARCH_TYPES,
@@ -41,8 +47,6 @@ import {
import {
filterWeaponResults,
type SelectedWeapon,
saveRecentWeapon,
useRecentWeapons,
WeaponDestinationMenu,
WeaponResultsList,
weaponToSelectedWeapon,
@@ -56,8 +60,6 @@ const SEARCH_TYPE_TO_PREFIX: Record<SearchType, string> = {
tournaments: "to",
};
const STORAGE_KEY = "global-search-search-type";
function searchTypeIconPath(type: SearchType): string {
if (type === "weapons") {
return weaponCategoryUrl("SHOOTERS");
@@ -71,27 +73,6 @@ function searchTypeIconPath(type: SearchType): string {
return navIconUrl(navIcons[type]);
}
function getInitialSearchType(): SearchType {
if (typeof window === "undefined") return "weapons";
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SEARCH_TYPES.includes(stored as SearchType)) {
return stored as SearchType;
}
} catch {
// localStorage may be unavailable
}
return "weapons";
}
function persistSearchType(type: SearchType) {
try {
localStorage.setItem(STORAGE_KEY, type);
} catch {
// localStorage may be unavailable
}
}
export function GlobalSearch() {
const { t } = useTranslation(["common"]);
const [params, setParams] = useSearchParamsTyped(globalSearchSearchParams);
@@ -179,7 +160,7 @@ function GlobalSearchContent({
const { t } = useTranslation(["common", "weapons"]);
const [query, setQuery] = React.useState("");
const [searchType, setSearchType] = React.useState<SearchType>(
initialSearchType ?? getInitialSearchType(),
() => initialSearchType ?? PersistedState.read(searchTypePersisted),
);
const [selectedWeapon, setSelectedWeapon] =
React.useState<SelectedWeapon | null>(
@@ -254,7 +235,7 @@ function GlobalSearchContent({
const handleSearchTypeChange = (value: string) => {
setSearchType(value as SearchType);
persistSearchType(value as SearchType);
PersistedState.write(searchTypePersisted, value as SearchType);
setSelectedWeapon(null);
};
@@ -273,7 +254,7 @@ function GlobalSearchContent({
);
if (matchedType) {
setSearchType(matchedType);
persistSearchType(matchedType);
PersistedState.write(searchTypePersisted, matchedType);
setSelectedWeapon(null);
setQuery("");
return;

View File

@@ -11,7 +11,7 @@ import {
Users,
Videotape,
} from "lucide-react";
import * as React from "react";
import type * as React from "react";
import { ListBox, ListBoxItem } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
@@ -307,61 +307,3 @@ export function WeaponResultsList({
</ListBox>
);
}
const RECENT_WEAPONS_KEY = "command-palette-recent-weapons";
const MAX_RECENT_WEAPONS = 5;
const recentWeaponsListeners = new Set<() => void>();
function subscribeRecentWeapons(listener: () => void) {
recentWeaponsListeners.add(listener);
window.addEventListener("storage", listener);
return () => {
recentWeaponsListeners.delete(listener);
window.removeEventListener("storage", listener);
};
}
function getRecentWeaponsSnapshot() {
try {
return localStorage.getItem(RECENT_WEAPONS_KEY) ?? "[]";
} catch {
return "[]";
}
}
function parseRecentWeapons(raw: string): MainWeaponId[] {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(id): id is MainWeaponId =>
typeof id === "number" && mainWeaponIds.includes(id as MainWeaponId),
);
} catch {
return [];
}
}
export function useRecentWeapons(): MainWeaponId[] {
const raw = React.useSyncExternalStore(
subscribeRecentWeapons,
getRecentWeaponsSnapshot,
() => "[]",
);
return parseRecentWeapons(raw);
}
export function saveRecentWeapon(weaponId: MainWeaponId): void {
try {
const recent = parseRecentWeapons(getRecentWeaponsSnapshot());
const filtered = recent.filter((id) => id !== weaponId);
const updated = [weaponId, ...filtered].slice(0, MAX_RECENT_WEAPONS);
localStorage.setItem(RECENT_WEAPONS_KEY, JSON.stringify(updated));
} catch {
// localStorage may be unavailable
}
for (const listener of recentWeaponsListeners) {
listener();
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/persisted-state/persisted-state-test-utils";
import {
recentWeaponsPersisted,
searchTypePersisted,
} from "./global-search-persisted";
describe("searchTypePersisted", () => {
it("round-trips", () => {
assertRoundTrips(searchTypePersisted, [
"weapons",
"users",
"teams",
"organizations",
"tournaments",
]);
});
it("decodes legacy plain-string values", () => {
expect(searchTypePersisted.decode("users")).toBe("users");
});
it("malformed values decode to the default", () => {
assertDecodesToDefault(searchTypePersisted, ["USER", "[1]"]);
});
});
describe("recentWeaponsPersisted", () => {
it("round-trips", () => {
assertRoundTrips(recentWeaponsPersisted, [[], [0, 10, 8000]]);
});
it("malformed values decode to the default", () => {
assertDecodesToDefault(recentWeaponsPersisted, ["not json", "[99999]"]);
});
});

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import { usePersistedState } from "~/modules/persisted-state/hooks";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
import { numericEnum } from "~/utils/zod";
import { GLOBAL_SEARCH_TYPES } from "./global-search-search-params";
const MAX_RECENT_WEAPONS = 5;
export const searchTypePersisted = PersistedState.define({
key: "global-search-search-type",
storage: "local",
schema: z.enum(GLOBAL_SEARCH_TYPES),
default: "weapons",
});
export const recentWeaponsPersisted = PersistedState.define({
key: "command-palette-recent-weapons",
storage: "local",
schema: z.array(numericEnum(mainWeaponIds)),
default: [],
});
export function useRecentWeapons(): MainWeaponId[] {
const [recentWeapons] = usePersistedState(recentWeaponsPersisted);
return recentWeapons;
}
export function saveRecentWeapon(weaponId: MainWeaponId) {
PersistedState.write(
recentWeaponsPersisted,
PersistedState.prependToRecentList(
PersistedState.read(recentWeaponsPersisted),
weaponId,
MAX_RECENT_WEAPONS,
),
);
}

View File

@@ -1,8 +1,9 @@
import { Crosshair } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { weaponReportDefaultOpenSchema } from "~/features/settings/settings-schemas";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import type {
MainWeaponId,
ModeShort,
@@ -47,7 +48,10 @@ export function WeaponReporter({
}: WeaponReporterProps) {
const { t } = useTranslation(["q", "game-misc", "common"]);
const user = useUser();
const fetcher = useFetcher();
const persistDefaultOpen = useActionSubmit(weaponReportDefaultOpenSchema, {
action: SETTINGS_PAGE,
encType: "application/json",
});
const [isOpen, setIsOpen] = useState(
() => user?.preferences.weaponReportDefaultOpen ?? false,
);
@@ -64,10 +68,9 @@ export function WeaponReporter({
const handleToggle = (newOpen: boolean) => {
setIsOpen(newOpen);
fetcher.submit(
{ _action: "UPDATE_WEAPON_REPORT_DEFAULT_OPEN", newValue: newOpen },
{ method: "post", action: SETTINGS_PAGE, encType: "application/json" },
);
persistDefaultOpen.submit("UPDATE_WEAPON_REPORT_DEFAULT_OPEN", {
newValue: newOpen,
});
};
return (

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
import { _action, weaponSplId } from "~/utils/zod";
const reportedMapIndex = z.coerce.number().int().nonnegative();
export const reportWeaponSchema = z.object({
_action: _action("REPORT_WEAPON"),
weaponSplId,
mapIndex: reportedMapIndex,
});
export const undoWeaponReportSchema = z.object({
_action: _action("UNDO_WEAPON_REPORT"),
mapIndex: reportedMapIndex,
});
/** Weapon reporting actions shared by every match page route action schema. */
export const weaponReportActionSchema = z.union([
reportWeaponSchema,
undoWeaponReportSchema,
]);

View File

@@ -1,6 +1,7 @@
import { useFetcher } from "react-router";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { weaponReportActionSchema } from "./match-page-schemas";
import type { WeaponReporterMap, WeaponReporterProps } from "./WeaponReporter";
/**
@@ -20,7 +21,7 @@ export function useMatchWeaponReport({
maps: WeaponReporterMap[];
pastReported: { mapIndex: number; weaponSplId: MainWeaponId }[];
}): WeaponReporterProps {
const weaponFetcher = useFetcher();
const weaponReport = useActionSubmit(weaponReportActionSchema);
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
useRecentlyReportedWeapons();
@@ -39,28 +40,18 @@ export function useMatchWeaponReport({
.map((w) => w.weaponSplId),
nextMapIndex,
quickSelectWeaponIds: recentlyReportedWeapons,
isSubmitting: weaponFetcher.state !== "idle",
isSubmitting: weaponReport.state !== "idle",
onSubmit: (weaponSplId) => {
addRecentlyReportedWeapon(weaponSplId);
if (nextMapIndex < 0) return;
weaponFetcher.submit(
{
_action: "REPORT_WEAPON",
weaponSplId: String(weaponSplId),
mapIndex: String(nextMapIndex),
},
{ method: "post" },
);
weaponReport.submit("REPORT_WEAPON", {
weaponSplId,
mapIndex: nextMapIndex,
});
},
onUndo: () => {
if (undoMapIndex < 0) return;
weaponFetcher.submit(
{
_action: "UNDO_WEAPON_REPORT",
mapIndex: String(undoMapIndex),
},
{ method: "post" },
);
weaponReport.submit("UNDO_WEAPON_REPORT", { mapIndex: undoMapIndex });
},
};
}

View File

@@ -106,7 +106,7 @@ async function seedPendingTrophies({
...submission(index),
name: `Accepted trophy ${index + 1}`,
}),
{ approverUserIds: [users.adminId, users.staffId] },
{ approverUserIds: [users.adminId, users.staffId, users.orgAdminId] },
);
await TrophyFactory.createManyPending(

View File

@@ -200,6 +200,7 @@ export interface PendingTrophy {
declineReason: string | null;
declinedAt: number | null;
declinedByUserId: number | null;
acceptedAt: number | null;
targetTrophyId: number | null;
managerId: number | null;
}

View File

@@ -21,6 +21,7 @@ import {
everyTwoMinutes,
} from "./routines/list.server";
import { loadAllDateFnsLocales } from "./utils/dates";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
import { logger } from "./utils/logger";
// Reject/cancel all pending promises after 5 seconds
@@ -89,7 +90,7 @@ declare global {
var appStartSignal: undefined | true;
}
if (!global.appStartSignal && ServerConfig.isProduction) {
if (!global.appStartSignal && ServerConfig.isProduction && !IS_E2E_TEST_RUN) {
global.appStartSignal = true;
cron.schedule("0 */1 * * *", async () => {

View File

@@ -7,8 +7,8 @@ export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;
export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
// hfcRed
export const DEV_IDS = [27883];
// hfcRed Dreamy Cafy
export const QA_IDS: number[] = [27883, 38176, 10654];
// hfcRed Dreamy Cafy Acing
export const QA_IDS: number[] = [27883, 38176, 10654, 9342];
export const STAFF_DISCORD_IDS = [
"138757634500067328",

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import type { MetaFunction } from "react-router";
import { Form, Link, useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Form, useFetcher, useLoaderData } from "react-router";
import { Catcher } from "~/components/Catcher";
import { SendouButton } from "~/components/elements/Button";
import {
@@ -13,16 +12,12 @@ import {
import { UserSearch } from "~/components/elements/UserSearch";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { UserLink } from "~/components/UserLink";
import { SendouForm } from "~/form/SendouForm";
import { useHasRole } from "~/modules/permissions/hooks";
import { useSearchParam } from "~/modules/search-params/hooks";
import { metaTags } from "~/utils/remix";
import {
impersonateUrl,
SEED_URL,
STOP_IMPERSONATING_URL,
userPage,
} from "~/utils/urls";
import { impersonateUrl, SEED_URL, STOP_IMPERSONATING_URL } from "~/utils/urls";
import { action } from "../actions/admin.server";
import {
banUserSchema,
@@ -100,14 +95,7 @@ function FriendCodeLookUp() {
</SendouForm>
<div className="stack lg">
{data.friendCodeSearchUsers?.map((user) => (
<Link
key={user.id}
to={userPage(user)}
className="stack horizontal sm text-main-forced items-center"
>
<Avatar user={user} size="sm" />
{user.username}
</Link>
<UserLink key={user.id} user={user} size="sm" />
))}
</div>
</div>

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { wrappedLoader } from "~/utils/Test";
import type { GetCalendarWeekResponse } from "../schema";
import { loader } from "./calendar.$year.$week";
const weekLoader = wrappedLoader<Response>({ loader });
const fetchWeek = async (year: number, week: number) => {
const response = await weekLoader({
params: { year: String(year), week: String(week) },
});
return (await response.json()) as GetCalendarWeekResponse;
};
describe("GET /api/calendar/:year/:week", () => {
test("an event starting exactly at the week boundary is returned for exactly one week", async () => {
const user = await UserFactory.createRegular();
// Monday 2025-01-13 00:00 UTC, i.e. Sunday 7 PM EST — the boundary
// between ISO weeks 2 and 3 of 2025
await CalendarEventFactory.create({
authorId: user.id,
startTimes: [dateToDatabaseTimestamp(new Date("2025-01-13T00:00:00Z"))],
});
const weekTwoEvents = await fetchWeek(2025, 2);
const weekThreeEvents = await fetchWeek(2025, 3);
expect(weekTwoEvents.length + weekThreeEvents.length).toBe(1);
});
});

View File

@@ -55,7 +55,7 @@ function fetchEventsOfWeek(args: { week: number; year: number }) {
">=",
dateToDatabaseTimestamp(startTime),
)
.where("CalendarEventDate.startsAt", "<=", dateToDatabaseTimestamp(endTime))
.where("CalendarEventDate.startsAt", "<", dateToDatabaseTimestamp(endTime))
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEventDate.startsAt", "asc")
.execute();

View File

@@ -8,6 +8,7 @@ import { notify } from "~/features/notifications/core/notify.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
@@ -43,7 +44,7 @@ export const action = async (args: ActionFunctionArgs) => {
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB({ tournamentId, user });
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
requireTournamentOrganizer(tournament, user);
const team = tournament.teamById(teamId);
errorToastIfFalsy(team, "Invalid team id");

View File

@@ -5,6 +5,7 @@ import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournam
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server";
@@ -38,7 +39,7 @@ export const action = async (args: ActionFunctionArgs) => {
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB({ tournamentId, user });
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
requireTournamentOrganizer(tournament, user);
const team = tournament.teamById(teamId);
errorToastIfFalsy(team, "Invalid team id");

View File

@@ -4,12 +4,12 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { inGameNameIsValid } from "~/features/user-page/in-game-name";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseBody,
parseParams,
} from "~/utils/remix.server";
@@ -39,7 +39,7 @@ export const action = async (args: ActionFunctionArgs) => {
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB({ tournamentId, user });
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
requireTournamentOrganizer(tournament, user);
const teamMemberOf = badRequestIfFalsy(
tournament.teamMemberOfByUser({ id: userId }),

View File

@@ -1,15 +1,11 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestPayload, successToast } from "~/utils/remix.server";
import * as ApiRepository from "../ApiRepository.server";
import { apiActionSchema } from "../api-schemas";
import { checkUserHasApiAccess } from "../core/perms";
const apiActionSchema = z.object({
_action: z.enum(["GENERATE_READ", "GENERATE_WRITE"]),
});
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestPayload({
request,

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
import { _action } from "~/utils/zod";
export const apiActionSchema = z.union([
z.object({
_action: _action("GENERATE_READ"),
}),
z.object({
_action: _action("GENERATE_WRITE"),
}),
]);

View File

@@ -2,15 +2,15 @@ import { Eye, RefreshCcw } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { metaTags } from "~/utils/remix";
import { API_DOC_LINK } from "~/utils/urls";
import { action } from "../actions/api.server";
import { apiActionSchema } from "../api-schemas";
import { loader } from "../loaders/api.server";
export { action, loader };
@@ -70,7 +70,7 @@ function TokenSection({
}: {
token: string | null;
tokenType: "read" | "write";
generateAction: string;
generateAction: "GENERATE_READ" | "GENERATE_WRITE";
}) {
const { t } = useTranslation(["common"]);
@@ -90,35 +90,33 @@ function TokenSection({
</div>
{token ? (
<div className="stack md">
<div>
<CopyToClipboardPopover
url={token}
trigger={
<SendouButton icon={<Eye />}>
{t("common:api.revealButton")}
</SendouButton>
}
/>
</div>
<FormWithConfirm
dialogHeading={t("common:api.regenerate.heading")}
submitButtonText={t("common:api.regenerate.confirm")}
fields={[["_action", generateAction]]}
>
<SendouButton variant="outlined" icon={<RefreshCcw />}>
{t("common:api.regenerate.button")}
</SendouButton>
</FormWithConfirm>
<div>
<CopyToClipboardPopover
url={token}
trigger={
<SendouButton icon={<Eye />}>
{t("common:api.revealButton")}
</SendouButton>
}
/>
</div>
) : (
<form method="post">
<SubmitButton _action={generateAction}>
{t("common:api.generate")}
</SubmitButton>
</form>
)}
) : null}
<ActionButton
schema={apiActionSchema}
action={generateAction}
variant={token ? "outlined" : undefined}
icon={token ? <RefreshCcw /> : undefined}
confirm={
token
? {
dialogHeading: t("common:api.regenerate.heading"),
submitButtonText: t("common:api.regenerate.confirm"),
}
: undefined
}
>
{token ? t("common:api.regenerate.button") : t("common:api.generate")}
</ActionButton>
</div>
);
}

View File

@@ -4,8 +4,8 @@ import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
customAvatarUrl,
} from "~/utils/kysely.server";
import { seededRandom } from "~/utils/random";
import type { ListedArt } from "./art-types";
@@ -54,12 +54,8 @@ export async function findShowcaseArts(): Promise<ListedArt[]> {
"Art.id",
"Art.createdAt",
"Art.isShowcase",
"User.id as userId",
"User.discordId",
"User.username",
"User.discordAvatar",
...commonUserSelect(eb, { idAs: "userId" }),
"User.commissionsOpen",
customAvatarUrl(eb).as("customAvatarUrl"),
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
@@ -99,12 +95,8 @@ export async function findShowcaseArtsByTag(
"Art.id",
"Art.createdAt",
"Art.isShowcase",
"User.id as userId",
"User.discordId",
"User.username",
"User.discordAvatar",
...commonUserSelect(eb, { idAs: "userId" }),
"User.commissionsOpen",
customAvatarUrl(eb).as("customAvatarUrl"),
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
@@ -150,11 +142,8 @@ export async function findRecentlyUploadedArts(): Promise<ListedArt[]> {
"Art.id",
"Art.createdAt",
"Art.isShowcase",
"User.discordId",
"User.username",
"User.discordAvatar",
...commonUserSelect(eb, { idAs: "userId" }),
"User.commissionsOpen",
customAvatarUrl(eb).as("customAvatarUrl"),
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
@@ -209,11 +198,8 @@ export async function findArtsByUserId(
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
"User.discordId",
"User.username",
"User.discordAvatar",
...commonUserSelect(eb, { idAs: "userId" }),
"User.commissionsOpen",
customAvatarUrl(eb).as("customAvatarUrl"),
jsonArrayFrom(
eb
.selectFrom("TaggedArt")
@@ -229,12 +215,9 @@ export async function findArtsByUserId(
"LinkedUser.id",
"ArtUserMetadata.userId",
)
.select([
"LinkedUser.id",
"LinkedUser.discordId",
"LinkedUser.username",
"LinkedUser.customUrl",
])
.select((linkedEb) =>
commonUserSelect(linkedEb, { alias: "LinkedUser" }),
)
.whereRef("ArtUserMetadata.artId", "=", "Art.id"),
).as("linkedUsers"),
])
@@ -270,12 +253,9 @@ export async function findArtsByUserId(
"LinkedUser.id",
"ArtUserMetadata.userId",
)
.select([
"LinkedUser.id",
"LinkedUser.discordId",
"LinkedUser.username",
"LinkedUser.customUrl",
])
.select((linkedEb) =>
commonUserSelect(linkedEb, { alias: "LinkedUser" }),
)
.whereRef("ArtUserMetadata.artId", "=", "Art.id"),
).as("linkedUsers"),
])

View File

@@ -1,4 +1,5 @@
import type { Tables } from "~/db/tables";
import type { CommonUser } from "~/utils/kysely.server";
export interface ListedArt {
id: Tables["Art"]["id"];
@@ -10,12 +11,7 @@ export interface ListedArt {
id: Tables["ArtTag"]["id"];
name: Tables["ArtTag"]["name"];
}>;
linkedUsers?: Array<{
id: Tables["User"]["id"];
discordId: Tables["User"]["discordId"];
username: Tables["User"]["username"];
customUrl: Tables["User"]["customUrl"];
}>;
linkedUsers?: Array<CommonUser>;
author?: {
discordId: Tables["User"]["discordId"];
username: Tables["User"]["username"];

View File

@@ -65,6 +65,9 @@ export default function ArticlePage() {
day: "numeric",
month: "numeric",
year: "numeric",
// article dates are calendar dates parsed as UTC midnight;
// formatting in the viewer's zone would shift the day west of UTC
timeZone: "UTC",
}}
/>
</div>

View File

@@ -54,6 +54,9 @@ export default function ArticlesMainPage() {
day: "numeric",
month: "numeric",
year: "numeric",
// article dates are calendar dates parsed as UTC midnight;
// formatting in the viewer's zone would shift the day west of UTC
timeZone: "UTC",
}}
/>
</div>

View File

@@ -124,6 +124,19 @@ describe("isVisible", () => {
expect(Association.isVisible(args)).toBe(false);
});
it("should return false for a non-friend when the viewer has the FRIENDS virtual association every user gets", () => {
const args: Association.IsVisibleArgs = {
visibility: { forAssociation: "FRIENDS" },
associations: {
actual: [],
virtual: ["FRIENDS"],
friendIds: [99],
},
contentOwnerUserId: 42,
};
expect(Association.isVisible(args)).toBe(false);
});
it("should return false for FRIENDS visibility when not logged in", () => {
const args: Association.IsVisibleArgs = {
visibility: { forAssociation: "FRIENDS" },

View File

@@ -43,8 +43,11 @@ export function isVisible(args: IsVisibleArgs) {
args.associations?.actual.some((association) =>
currentVisibility.includes(association.id),
) ||
args.associations?.virtual.some((association) =>
currentVisibility.includes(association as any),
args.associations?.virtual.some(
(association) =>
// "FRIENDS" is a sentinel every user has, handled by the friendship check above
association !== "FRIENDS" &&
currentVisibility.includes(association as any),
) ||
false
);

View File

@@ -1,13 +1,13 @@
import { Check, Clipboard, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, Outlet, useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Outlet, useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { UserLink } from "~/components/UserLink";
import { action } from "~/features/associations/actions/associations.server";
import {
type AssociationsLoaderData,
@@ -17,7 +17,8 @@ import { useUser } from "~/features/auth/core/user";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useHasPermission } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { associationsPage, userPage } from "~/utils/urls";
import { associationsPage } from "~/utils/urls";
import { associationsPageActionSchema } from "../associations-schemas";
export { action, loader };
@@ -57,27 +58,26 @@ function Header() {
function JoinForm() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const { t } = useTranslation(["common", "scrims"]);
if (!data.toJoin) return null;
return (
<fetcher.Form method="post" className="stack horizontal md items-center">
<input type="hidden" name="inviteCode" value={data.toJoin.inviteCode} />
<div className="stack horizontal md items-center">
<Label spaced={false}>
{t("scrims:associations.join.title", {
name: data.toJoin.association.name,
})}
</Label>
<SubmitButton
<ActionButton
schema={associationsPageActionSchema}
action="JOIN_ASSOCIATION"
fields={{ inviteCode: data.toJoin.inviteCode }}
size="small"
_action="JOIN_ASSOCIATION"
state={fetcher.state}
>
{t("common:actions.join")}
</SubmitButton>
</fetcher.Form>
</ActionButton>
</div>
);
}
@@ -172,7 +172,6 @@ function AssociationInviteCodeActions({
}) {
const { t } = useTranslation(["common", "scrims"]);
const { copyToClipboard, copySuccess } = useCopyToClipboard();
const fetcher = useFetcher();
const id = React.useId();
const inviteLink = `https://sendou.ink${associationsPage(inviteCode)}`;
@@ -190,17 +189,15 @@ function AssociationInviteCodeActions({
aria-label="Copy to clipboard"
/>
</div>
<fetcher.Form method="post">
<input type="hidden" name="associationId" value={associationId} />
<SubmitButton
variant="minimal-destructive"
size="small"
_action="REFRESH_INVITE_CODE"
state={fetcher.state}
>
{t("scrims:associations.shareLink.reset")}
</SubmitButton>
</fetcher.Form>
<ActionButton
schema={associationsPageActionSchema}
action="REFRESH_INVITE_CODE"
fields={{ associationId }}
variant="minimal-destructive"
size="small"
>
{t("scrims:associations.shareLink.reset")}
</ActionButton>
</div>
);
}
@@ -220,13 +217,7 @@ function AssociationMember({
return (
<div className="stack horizontal sm items-center justify-between">
<Link
to={userPage(member)}
className="text-main-forced stack horizontal sm"
>
<Avatar size="xxs" user={member} />
{member.username}
</Link>
<UserLink user={member} />
{showControls ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.removeMember.title", {

View File

@@ -12,7 +12,7 @@ import { assertUnreachable } from "~/utils/types";
import { badgePage } from "~/utils/urls";
import { actualNumber } from "~/utils/zod";
import * as BadgeRepository from "../BadgeRepository.server";
import { editBadgeActionSchema } from "../badges-schemas.server";
import { editBadgeActionSchema } from "../badges-schemas";
export const action: ActionFunction = async ({ request, params }) => {
const data = await parseRequestPayload({

View File

@@ -1,6 +1,7 @@
import { Trash } from "lucide-react";
import * as React from "react";
import { Form, useMatches, useOutletContext } from "react-router";
import { useMatches, useOutletContext } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
@@ -9,6 +10,7 @@ import type { Tables } from "~/db/tables";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { action } from "../actions/badges.$id.edit.server";
import styles from "../badges.module.css";
import { editBadgeActionSchema } from "../badges-schemas";
import type { BadgeDetailsLoaderData } from "../loaders/badges.$id.server";
import type { BadgeDetailsContext } from "./badges.$id";
@@ -27,11 +29,11 @@ export default function EditBadgePage() {
heading={`Editing winners of ${badge.displayName}`}
onCloseTo={parentMatch.pathname}
>
<Form method="post" className="stack md">
<div className="stack md">
{isStaff ? <Managers data={data} /> : null}
{isStaff && canManageBadge ? <Divider className="mt-2" /> : null}
{canManageBadge ? <Owners data={data} /> : null}
</Form>
</div>
</SendouDialog>
);
}
@@ -88,20 +90,15 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
))}
</ul>
</div>
<input
type="hidden"
name="managerIds"
value={JSON.stringify(managers.map((m) => m.id))}
/>
<div>
<SendouButton
type="submit"
<ActionButton
schema={editBadgeActionSchema}
action="MANAGERS"
fields={{ managerIds: managers.map((m) => m.id) }}
isDisabled={amountOfChanges === 0}
name="_action"
value="MANAGERS"
>
{submitButtonText(amountOfChanges)}
</SendouButton>
</ActionButton>
</div>
</div>
);
@@ -202,20 +199,15 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
))}
</ul>
) : null}
<input
type="hidden"
name="ownerIds"
value={JSON.stringify(countArrayToDuplicatedIdsArray(owners))}
/>
<div>
<SendouButton
type="submit"
<ActionButton
schema={editBadgeActionSchema}
action="OWNERS"
fields={{ ownerIds: countArrayToDuplicatedIdsArray(owners) }}
isDisabled={ownerDifferences.length === 0}
name="_action"
value="OWNERS"
>
Submit
</SendouButton>
</ActionButton>
</div>
</div>
);

View File

@@ -10,9 +10,10 @@ import { isAbility } from "~/modules/in-game-lists/utils";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { analyzerSearchParams } from "./analyzer-search-params";
import type { SpecialEffectType } from "./analyzer-types";
import { buildToAbilityPoints } from "./core/ability-points";
import { applySpecialEffects, SPECIAL_EFFECTS } from "./core/specialEffects";
import { buildStats } from "./core/stats";
import { buildIsEmpty, buildToAbilityPoints } from "./core/utils";
import { buildIsEmpty } from "./core/utils";
export function useAnalyzeBuild() {
const [params, setParams] = useSearchParamsTyped(analyzerSearchParams);

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import { buildToAbilityPoints } from "./utils";
import { buildToAbilityPoints } from "./ability-points";
describe("buildToAbilityPoints", () => {
const EMPTY_ROW: [

View File

@@ -0,0 +1,58 @@
import { abilities } from "~/modules/in-game-lists/abilities";
import type {
Ability,
AbilityWithUnknown,
BuildAbilitiesTupleWithUnknown,
} from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { MAIN_SLOT_AP, SUB_SLOT_AP } from "../analyzer-constants";
import type { AbilityPoints } from "../analyzer-types";
/**
* Sums a build's stackable ability points per ability, accounting for
* Ability Doubler doubling the sub slots of its row. Main-only abilities are
* left out as they have no ability point value.
*/
export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) {
const result: AbilityPoints = new Map();
for (const abilityRow of build) {
let abilityDoublerActive = false;
for (const [i, ability] of abilityRow.entries()) {
if (ability === "AD") {
abilityDoublerActive = true;
}
if (!isStackableAbility(ability) && ability !== "UNKNOWN") {
continue;
}
const aps = i === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP;
const apsDoubled = aps * (abilityDoublerActive ? 2 : 1);
const newAp = (result.get(ability) ?? 0) + apsDoubled;
result.set(ability, newAp);
}
}
return result;
}
/** Whether the ability stacks in sub slots as ability points (e.g. ISM) as opposed to a main-only ability (e.g. SJ). */
export function isStackableAbility(
ability: AbilityWithUnknown,
): ability is Ability {
if (ability === "UNKNOWN") return false;
const abilityObj = abilities.find((a) => a.name === ability);
invariant(abilityObj);
return abilityObj.type === "STACKABLE";
}
/** Whether the ability only exists in the main slot of one gear type (e.g. SJ). */
export function isMainOnlyAbility(
ability: AbilityWithUnknown,
): ability is Ability {
if (ability === "UNKNOWN") return false;
return !isStackableAbility(ability);
}

View File

@@ -1,7 +1,5 @@
import { abilities } from "~/modules/in-game-lists/abilities";
import type {
Ability,
AbilityWithUnknown,
BuildAbilitiesTupleWithUnknown,
MainWeaponId,
SpecialWeaponId,
@@ -9,7 +7,6 @@ import type {
import { weaponIdToBaseWeaponId } from "~/modules/in-game-lists/weapon-ids";
import invariant from "~/utils/invariant";
import type { Unpacked } from "~/utils/types";
import { MAIN_SLOT_AP, SUB_SLOT_AP } from "../analyzer-constants";
import type {
AbilityPoints,
AnalyzedBuild,
@@ -43,48 +40,6 @@ export function specialWeaponParams(
return params.specialWeapons[specialWeaponId] as SpecialWeaponParams;
}
export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) {
const result: AbilityPoints = new Map();
for (const abilityRow of build) {
let abilityDoublerActive = false;
for (const [i, ability] of abilityRow.entries()) {
if (ability === "AD") {
abilityDoublerActive = true;
}
if (!isStackableAbility(ability) && ability !== "UNKNOWN") {
continue;
}
const aps = i === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP;
const apsDoubled = aps * (abilityDoublerActive ? 2 : 1);
const newAp = (result.get(ability) ?? 0) + apsDoubled;
result.set(ability, newAp);
}
}
return result;
}
export function isStackableAbility(
ability: AbilityWithUnknown,
): ability is Ability {
if (ability === "UNKNOWN") return false;
const abilityObj = abilities.find((a) => a.name === ability);
invariant(abilityObj);
return abilityObj.type === "STACKABLE";
}
export function isMainOnlyAbility(
ability: AbilityWithUnknown,
): ability is Ability {
if (ability === "UNKNOWN") return false;
return !isStackableAbility(ability);
}
export function apFromMap({
abilityPoints,
ability,

View File

@@ -77,6 +77,7 @@ import type {
} from "../analyzer-types";
import { INK_CONSUME_TYPES } from "../analyzer-types";
import { PerInkTankGrid } from "../components/PerInkTankGrid";
import { isMainOnlyAbility, isStackableAbility } from "../core/ability-points";
import {
ABILITIES_WITHOUT_CHUNKS,
getAbilityChunksMapAsArray,
@@ -86,12 +87,7 @@ import {
SPECIAL_EFFECTS,
} from "../core/specialEffects";
import { buildStats } from "../core/stats";
import {
buildIsEmpty,
damageIsSubWeaponDamage,
isMainOnlyAbility,
isStackableAbility,
} from "../core/utils";
import { buildIsEmpty, damageIsSubWeaponDamage } from "../core/utils";
import styles from "./analyzer.module.css";
export const CURRENT_PATCH = "11.2";

View File

@@ -3,7 +3,7 @@ import type { Ability } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { roundToNDecimalPlaces } from "~/utils/number";
import { MAX_AP } from "../build-analyzer/analyzer-constants";
import { isStackableAbility } from "../build-analyzer/core/utils";
import { isStackableAbility } from "../build-analyzer/core/ability-points";
import type {
AverageAbilityPointsResult,
PopularBuildsRow,

View File

@@ -3,6 +3,7 @@ import * as BuildFactory from "~/db/seed/factories/BuildFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory";
import { db } from "~/db/sql";
import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points";
import type {
BuildAbilitiesTuple,
MainWeaponId,
@@ -113,6 +114,26 @@ describe("BuildRepository.insert — computeBuildData", () => {
expect(sums).toContainEqual({ ability: "ISS", abilityPoints: 19 });
});
test("agrees with the analyzer's AP calculation for Ability Doubler builds", async () => {
const abilitiesWithDoubler: BuildAbilitiesTuple = [
["ISM", "ISM", "ISM", "ISM"],
["AD", "ISM", "ISM", "ISM"],
["SJ", "ISM", "ISM", "ISM"],
];
const { id } = await BuildRepository.insert(
baseArgs({ abilities: abilitiesWithDoubler }),
);
const sums = await buildAbilitySumsByBuildId(id);
const analyzerIsmAp =
buildToAbilityPoints(abilitiesWithDoubler).get("ISM");
expect(sums).toContainEqual({
ability: "ISM",
abilityPoints: analyzerIsmAp,
});
});
test("does not insert BuildAbilitySum rows for private builds", async () => {
const { id } = await BuildRepository.insert(baseArgs({ isPrivate: 1 }));

View File

@@ -14,10 +14,11 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
import { LimitReachedError } from "~/utils/errors";
import invariant from "~/utils/invariant";
import { commonUserJsonObject } from "~/utils/kysely.server";
import { MAIN_SLOT_AP } from "../build-analyzer/analyzer-constants";
import {
MAIN_SLOT_AP,
SUB_SLOT_AP,
} from "../build-analyzer/analyzer-constants";
buildToAbilityPoints,
isStackableAbility,
} from "../build-analyzer/core/ability-points";
import { BUILD } from "./builds-constants";
import { sortAbilities } from "./core/ability-sorting.server";
@@ -475,15 +476,18 @@ async function computeBuildData(
function computeAbilitySums(
abilities: BuildAbilitiesTuple,
): Array<[Ability, number]> {
const sums = new Map<Ability, number>();
const sums = buildToAbilityPoints(abilities);
// unlike the analyzer, the sums also track main-only abilities so that
// builds differing only by them get distinct signatures
for (const row of abilities) {
for (let slotIdx = 0; slotIdx < row.length; slotIdx++) {
const ability = row[slotIdx];
const ap = slotIdx === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP;
sums.set(ability, (sums.get(ability) ?? 0) + ap);
}
const mainAbility = row[0];
if (isStackableAbility(mainAbility)) continue;
sums.set(mainAbility, (sums.get(mainAbility) ?? 0) + MAIN_SLOT_AP);
}
return [...sums.entries()];
return [...sums.entries()] as Array<[Ability, number]>;
}
function serializeSignature(sums: Array<[Ability, number]>): string {

View File

@@ -1,5 +1,5 @@
import type { Tables } from "~/db/tables";
import { buildToAbilityPoints } from "~/features/build-analyzer/core/utils";
import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points";
import type {
BuildAbilitiesTuple,
ModeShort,

View File

@@ -22,8 +22,8 @@ import {
} from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
customAvatarUrl,
tournamentLogoWithDefault,
} from "~/utils/kysely.server";
import { calendarEventPage, tournamentPage } from "~/utils/urls";
@@ -337,12 +337,9 @@ export async function findById(
"CalendarEvent.participantCount",
"CalendarEvent.avatarImgId",
"Tournament.mapPickingStyle",
"User.id as authorId",
"CalendarEventDate.startsAt",
"CalendarEventDate.eventId",
"User.username",
"User.discordId",
"User.discordAvatar",
...commonUserSelect(eb, { idAs: "authorId" }),
hasBadge(eb),
tournamentOrganization(eb.ref("CalendarEvent.organizationId")).as(
"organization",
@@ -394,13 +391,8 @@ export async function findResultsByEventId(eventId: number) {
.selectFrom("CalendarEventResultPlayer")
.leftJoin("User", "User.id", "CalendarEventResultPlayer.userId")
.select((eb) => [
"CalendarEventResultPlayer.userId as id",
...commonUserSelect(eb),
"CalendarEventResultPlayer.name",
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
customAvatarUrl(eb).as("customAvatarUrl"),
])
.whereRef(
"CalendarEventResultPlayer.teamId",

View File

@@ -21,21 +21,21 @@ export const action: ActionFunction = async ({ params }) => {
await CalendarRepository.findById(parsedParams.id),
);
errorToastIfFalsy(
canDeleteCalendarEvent({
user,
event,
startTime: databaseTimestampToDate(event.startTimes[0]),
}),
"Cannot delete event",
);
if (event.tournamentId) {
errorToastIfFalsy(
(await BracketRepository.findByTournamentId(event.tournamentId)).stage
.length === 0,
"Tournament has already started",
);
} else {
errorToastIfFalsy(
canDeleteCalendarEvent({
user,
event,
startTime: databaseTimestampToDate(event.startTimes[0]),
}),
"Cannot delete event",
);
}
await CalendarRepository.deleteById({

View File

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { wrappedLoader } from "~/utils/Test";
import { type CalendarLoaderData, loader } from "./calendar.server";
const calendarLoader = wrappedLoader<CalendarLoaderData>({ loader });
const eventNames = (data: CalendarLoaderData) =>
data.eventTimes.flatMap((time) => [
...time.events.shown.map((event) => event.name),
...time.events.hidden.map((event) => event.name),
]);
describe("calendar loader default view", () => {
afterEach(() => {
vi.useRealTimers();
});
// The client resolves the shown week from the user's local clock while the
// loader resolves the fetched week from the server's clock. Around the week
// boundary these disagree by a full week for users ahead of UTC: e.g. in
// Auckland (UTC+13) Monday 2026-01-12 10:00 local is still Sunday 2026-01-11
// 21:00 UTC, so the user is shown the week Jan 12–18 but the loader only
// fetches up to ~Jan 13. Events from Tuesday evening onwards are missing.
test("fetches events for the whole week shown to a user ahead of UTC", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-01-11T21:00:00Z"));
const author = await UserFactory.createRegular();
await CalendarEventFactory.create({
authorId: author.id,
name: "Midweek Cup",
tags: null,
startTimes: [dateToDatabaseTimestamp(new Date("2026-01-14T06:00:00Z"))],
});
const data = await calendarLoader();
expect(eventNames(data)).toContain("Midweek Cup");
});
test("control: same event is returned once the server clock reaches the same week", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-01-12T12:00:00Z"));
const author = await UserFactory.createRegular();
await CalendarEventFactory.create({
authorId: author.id,
name: "Midweek Cup",
tags: null,
startTimes: [dateToDatabaseTimestamp(new Date("2026-01-14T06:00:00Z"))],
});
const data = await calendarLoader();
expect(eventNames(data)).toContain("Midweek Cup");
});
});

View File

@@ -31,9 +31,11 @@ export const loader = async (args: LoaderFunctionArgs) => {
const weekStart = startOfWeek(new Date(date), { weekStartsOn: 1 });
const events = await CalendarRepository.findAllBetweenTwoTimestamps({
// add a bit of tolerance to the timestamps to account for timezones
startTime: sub(weekStart, { hours: 24 }),
endTime: add(weekStart, { days: DAYS_SHOWN_AT_A_TIME + 1 }),
// on the default view the client resolves the shown week from its own clock,
// which around the week boundary can be a full week ahead of or behind the
// server's week, so fetch wide enough to cover every timezone's current week
startTime: sub(weekStart, { days: DAYS_SHOWN_AT_A_TIME + 1 }),
endTime: add(weekStart, { days: DAYS_SHOWN_AT_A_TIME * 2 + 1 }),
});
const filters = resolveFilters(args.request, user?.preferences);

View File

@@ -0,0 +1,49 @@
import { addDays } from "date-fns";
import { describe, expect, test } from "vitest";
import type { z } from "zod";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
import { action } from "./calendar.$id";
const deleteAction = wrappedAction<z.ZodType<Record<string, never>>>({
action,
});
describe("calendar event deletion", () => {
test("doesn't let a user delete someone else's tournament", async () => {
const admin = await UserFactory.createAdmin();
await UserFactory.createRegular();
const tournament = await TournamentFactory.create({
authorId: admin.id,
});
const response = await deleteAction(
{},
{ user: "regular", params: { id: String(tournament.eventId) } },
);
const eventAfter = await CalendarRepository.findById(tournament.eventId);
expect(eventAfter, "the tournament was deleted").toBeTruthy();
assertResponseErrored(response);
});
test("lets the author delete their own not-yet-started tournament", async () => {
await UserFactory.createAdmin();
const regular = await UserFactory.createRegular();
const tournament = await TournamentFactory.create({
authorId: regular.id,
startTimes: [dateToDatabaseTimestamp(addDays(new Date(), 7))],
});
await deleteAction(
{},
{ user: "regular", params: { id: String(tournament.eventId) } },
);
const eventAfter = await CalendarRepository.findById(tournament.eventId);
expect(eventAfter).toBeNull();
});
});

View File

@@ -2,7 +2,7 @@ import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
@@ -13,6 +13,7 @@ import { MapPoolStages } from "~/components/MapPoolSelector";
import { Placement } from "~/components/Placement";
import { Section } from "~/components/Section";
import { Table } from "~/components/Table";
import { UserLink } from "~/components/UserLink";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { databaseTimestampToDate } from "~/utils/dates";
@@ -25,7 +26,6 @@ import {
mapsPageWithMapPool,
navIconUrl,
resolveBaseUrl,
userPage,
} from "~/utils/urls";
import { metaTags, type SerializeFrom } from "../../../utils/remix";
import { action } from "../actions/calendar.$id.server";
@@ -232,18 +232,7 @@ function Results() {
key={player.name ? player.name : player.id}
className="flex items-center"
>
{player.name ? (
player.name
) : (
// as any but we know it's a user since it doesn't have name
<Link
to={userPage(player as any)}
className="stack horizontal xs items-center"
>
<Avatar user={player as any} size="xxs" />{" "}
{player.username}
</Link>
)}
<UserLink user={player} />
</li>
);
})}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/persisted-state/persisted-state-test-utils";
import { lastReadCountsPersisted } from "./chat-last-read";
describe("lastReadCountsPersisted", () => {
it("round-trips", () => {
assertRoundTrips(lastReadCountsPersisted, [0, 42]);
});
it("decodes legacy raw number strings", () => {
expect(lastReadCountsPersisted.decode("7")).toBe(7);
});
it("malformed values decode to the default", () => {
assertDecodesToDefault(lastReadCountsPersisted, ["abc", "", "Infinity"]);
});
});

View File

@@ -1,71 +1,23 @@
import * as React from "react";
import { z } from "zod";
import { usePersistedMapState } from "~/modules/persisted-state/hooks";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
const LOCAL_STORAGE_PREFIX = "chat_read__";
const listeners = new Set<() => void>();
let cachedCounts: Record<string, number> | null = null;
const SERVER_SNAPSHOT: Record<string, number> = {};
function readAllFromLocalStorage(): Record<string, number> {
const counts: Record<string, number> = {};
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(LOCAL_STORAGE_PREFIX)) continue;
const parsed = Number(localStorage.getItem(key));
counts[key.slice(LOCAL_STORAGE_PREFIX.length)] = Number.isFinite(parsed)
? parsed
: 0;
}
} catch {
// localStorage may be unavailable
}
return counts;
}
function getSnapshot() {
cachedCounts ??= readAllFromLocalStorage();
return cachedCounts;
}
function subscribe(listener: () => void) {
const handleStorage = (e: StorageEvent) => {
if (e.key !== null && !e.key.startsWith(LOCAL_STORAGE_PREFIX)) return;
cachedCounts = null;
listener();
};
listeners.add(listener);
window.addEventListener("storage", handleStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", handleStorage);
};
}
export const lastReadCountsPersisted = PersistedState.defineMap({
keyPrefix: "chat_read__",
storage: "local",
schema: z.number(),
default: 0,
});
/**
* The last read message count per chat room (chat code -> count), persisted in
* localStorage and kept in sync across tabs via the `storage` event.
*/
export function useLastReadCounts(): Record<string, number> {
return React.useSyncExternalStore(
subscribe,
getSnapshot,
() => SERVER_SNAPSHOT,
);
return usePersistedMapState(lastReadCountsPersisted);
}
/** Persists the last read message count for a room, notifying subscribers in this tab (other tabs sync via the `storage` event). */
export function writeLastReadCount(chatCode: string, count: number) {
try {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${chatCode}`, String(count));
} catch {
// localStorage may be unavailable
}
cachedCounts = null;
for (const listener of listeners) {
listener();
}
PersistedState.writeMapEntry(lastReadCountsPersisted, chatCode, count);
}

View File

@@ -17,6 +17,16 @@ describe("chatCodeVisible", () => {
expect(result).toBe(true);
});
test("not visible when just past expiration window", () => {
const result = chatAccessible({
isStaff: false,
expiresAfterDays: 1,
comparedTo: sub(new Date(), { days: 1, hours: 12 }),
});
expect(result).toBe(false);
});
test("not visible when past expiration window", () => {
const result = chatAccessible({
isStaff: false,

View File

@@ -12,7 +12,7 @@ export function chatAccessible(args: {
}): boolean {
const extraDays = args.isStaff ? STAFF_EXTRA_DAYS : 0;
return (
differenceInDays(new Date(), args.comparedTo) <=
differenceInDays(new Date(), args.comparedTo) <
args.expiresAfterDays + extraDays
);
}

View File

@@ -4,7 +4,7 @@ import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { commonUserSelect, customAvatarUrl } from "~/utils/kysely.server";
import { commonUserSelect } from "~/utils/kysely.server";
import { FRIEND } from "./friends-constants";
export async function findByUserIdWithActivity(userId: number) {
@@ -126,12 +126,7 @@ export async function findPendingSentRequests(senderId: number) {
.select((eb) => [
"FriendRequest.id",
"FriendRequest.createdAt",
"User.id as receiverId",
"User.username as receiverUsername",
"User.discordId as receiverDiscordId",
"User.discordAvatar as receiverDiscordAvatar",
"User.customUrl as receiverCustomUrl",
customAvatarUrl(eb).as("receiverCustomAvatarUrl"),
...commonUserSelect(eb, { prefix: "receiver" }),
])
.where("FriendRequest.senderId", "=", senderId)
.orderBy("FriendRequest.createdAt", "desc")
@@ -225,12 +220,7 @@ export async function findPendingReceivedRequests(receiverId: number) {
.select((eb) => [
"FriendRequest.id",
"FriendRequest.createdAt",
"User.id as senderId",
"User.username as senderUsername",
"User.discordId as senderDiscordId",
"User.discordAvatar as senderDiscordAvatar",
"User.customUrl as senderCustomUrl",
customAvatarUrl(eb).as("senderCustomAvatarUrl"),
...commonUserSelect(eb, { prefix: "sender" }),
])
.where("FriendRequest.receiverId", "=", receiverId)
.orderBy("FriendRequest.createdAt", "desc")

View File

@@ -1,7 +1,6 @@
import { Swords, Trash2, User } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import {
@@ -16,7 +15,10 @@ import {
type FriendActivityType,
friendActivityBadge,
} from "~/features/friends/friends-constants";
import { deleteFriendSchema } from "~/features/friends/friends-schemas";
import { frontPageSchema } from "~/features/sendouq/q-action-schemas";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import {
SENDOUQ_PAGE,
sendouQMatchPage,
@@ -67,7 +69,8 @@ export function FriendMenu({
month: "numeric",
year: "numeric",
});
const fetcher = useFetcher();
const joinQueue = useActionSubmit(frontPageSchema, { action: SENDOUQ_PAGE });
const deleteFriend = useActionSubmit(deleteFriendSchema);
const [confirmOpen, setConfirmOpen] = React.useState(false);
const friendSinceText = friendshipCreatedAt
@@ -118,10 +121,7 @@ export function FriendMenu({
<SendouMenuItem
icon={<Swords />}
onAction={() => {
fetcher.submit(
{ _action: "JOIN_QUEUE", direct: "true" },
{ method: "post", action: SENDOUQ_PAGE },
);
joinQueue.submit("JOIN_QUEUE", { direct: "true" });
onNavigate?.();
}}
>
@@ -172,13 +172,7 @@ export function FriendMenu({
<SendouButton
variant="destructive"
onPress={() => {
fetcher.submit(
{
_action: "DELETE_FRIEND",
friendshipId: String(friendshipId),
},
{ method: "post" },
);
deleteFriend.submit("DELETE_FRIEND", { friendshipId });
setConfirmOpen(false);
}}
>

View File

@@ -1,17 +1,22 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Form, Link, useLoaderData } from "react-router";
import { Link, useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { SendouForm } from "~/form/SendouForm";
import { markFriendRequestsSeen } from "~/hooks/useUnseenFriendRequests";
import { useSearchParam } from "~/modules/search-params/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { FriendMenu } from "../components/FriendMenu";
import { sendFriendRequestBaseSchema } from "../friends-schemas";
import {
acceptFriendRequestSchema,
cancelFriendRequestSchema,
declineFriendRequestSchema,
sendFriendRequestBaseSchema,
} from "../friends-schemas";
import {
friendsSearchParams,
VIEW_FILTERS,
@@ -91,28 +96,24 @@ function IncomingRequestsSection() {
<span className={styles.userName}>{request.sender.username}</span>
</Link>
<div className="stack horizontal sm">
<Form method="post">
<input type="hidden" name="_action" value="ACCEPT_REQUEST" />
<input
type="hidden"
name="friendRequestId"
value={request.id}
/>
<SubmitButton variant="outlined" size="miniscule">
{t("common:actions.accept")}
</SubmitButton>
</Form>
<Form method="post">
<input type="hidden" name="_action" value="DECLINE_REQUEST" />
<input
type="hidden"
name="friendRequestId"
value={request.id}
/>
<SubmitButton variant="minimal-destructive" size="miniscule">
{t("common:actions.decline")}
</SubmitButton>
</Form>
<ActionButton
schema={acceptFriendRequestSchema}
action="ACCEPT_REQUEST"
fields={{ friendRequestId: request.id }}
variant="outlined"
size="miniscule"
>
{t("common:actions.accept")}
</ActionButton>
<ActionButton
schema={declineFriendRequestSchema}
action="DECLINE_REQUEST"
fields={{ friendRequestId: request.id }}
variant="minimal-destructive"
size="miniscule"
>
{t("common:actions.decline")}
</ActionButton>
</div>
</div>
))}
@@ -144,13 +145,15 @@ function PendingRequestsSection() {
{request.receiver.username}
</span>
</Link>
<Form method="post">
<input type="hidden" name="_action" value="CANCEL_REQUEST" />
<input type="hidden" name="friendRequestId" value={request.id} />
<SubmitButton variant="minimal-destructive" size="miniscule">
{t("common:actions.cancel")}
</SubmitButton>
</Form>
<ActionButton
schema={cancelFriendRequestSchema}
action="CANCEL_REQUEST"
fields={{ friendRequestId: request.id }}
variant="minimal-destructive"
size="miniscule"
>
{t("common:actions.cancel")}
</ActionButton>
</div>
))}
</div>

View File

@@ -1,8 +1,11 @@
import { Download } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { usePersistedState } from "~/modules/persisted-state/hooks";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
import { APP_ICON_URL } from "~/utils/urls";
import styles from "./PWAInstallBanner.module.css";
@@ -13,19 +16,18 @@ interface BeforeInstallPromptEvent extends Event {
type InstallState = "hidden" | "native" | "ios" | "safari";
const SAFARI_MIN_INSTALL_VERSION = 17;
const BANNER_DISMISSED_KEY = "pwa-install-banner-dismissed";
const bannerDismissedPersisted = PersistedState.define({
key: "pwa-install-banner-dismissed",
storage: "local",
schema: z.boolean(),
default: false,
});
let deferredInstallPrompt: BeforeInstallPromptEvent | null = null;
let bannerDismissed = false;
const installStateListeners = new Set<() => void>();
if (typeof window !== "undefined") {
try {
bannerDismissed = localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
} catch {
// localStorage may be unavailable
}
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredInstallPrompt = event as BeforeInstallPromptEvent;
@@ -117,12 +119,15 @@ function InstallAction({
);
}
function useInstallState() {
return React.useSyncExternalStore(
function useInstallState(): InstallState {
const [bannerDismissed] = usePersistedState(bannerDismissedPersisted);
const promptInstallState = React.useSyncExternalStore(
subscribeToInstallState,
getInstallStateSnapshot,
getServerInstallStateSnapshot,
);
return bannerDismissed ? "hidden" : promptInstallState;
}
function subscribeToInstallState(callback: () => void) {
@@ -131,17 +136,10 @@ function subscribeToInstallState(callback: () => void) {
}
function dismissBanner() {
bannerDismissed = true;
try {
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
} catch {
// localStorage may be unavailable
}
notifyInstallStateChanged();
PersistedState.write(bannerDismissedPersisted, true);
}
function getInstallStateSnapshot(): InstallState {
if (bannerDismissed) return "hidden";
if (isStandalone()) return "hidden";
if (deferredInstallPrompt) return "native";
if (isIos()) return "ios";

View File

@@ -5,11 +5,9 @@ import { Image, ModeImage, WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import type { Tables } from "~/db/tables";
import type { AbilityPoints } from "~/features/build-analyzer/analyzer-types";
import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points";
import { getAbilityChunksMapAsArray } from "~/features/build-analyzer/core/abilityChunksCalc";
import {
apFromMap,
buildToAbilityPoints,
} from "~/features/build-analyzer/core/utils";
import { apFromMap } from "~/features/build-analyzer/core/utils";
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
import type {
Ability as AbilityType,

View File

@@ -8,7 +8,7 @@ import {
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as ImageRepository from "../ImageRepository.server";
import { validateImageSchema } from "../upload-schemas.server";
import { validateImageSchema } from "../upload-schemas";
export const action: ActionFunction = async ({ request }) => {
requireRole("STAFF");

View File

@@ -1,13 +1,12 @@
import { Trash } from "lucide-react";
import * as React from "react";
import { Form, Link, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Link, useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { action } from "../actions/upload.admin.server";
import { loader } from "../loaders/upload.admin.server";
import { validateImageSchema } from "../upload-schemas";
export { action, loader };
@@ -39,20 +38,18 @@ function ImageValidator() {
<div key={image.id}>
<div className="text-lg font-bold stack horizontal md">
{i + 1}){" "}
<FormWithConfirm
dialogHeading={`Reject image submitted by ${image.username}?`}
submitButtonText="Reject"
fields={[
["imageId", image.id],
["_action", "REJECT"],
]}
>
<SendouButton
icon={<Trash />}
variant="minimal-destructive"
size="medium"
/>
</FormWithConfirm>
<ActionButton
schema={validateImageSchema}
action="REJECT"
fields={{ imageId: image.id }}
confirm={{
dialogHeading: `Reject image submitted by ${image.username}?`,
submitButtonText: "Reject",
}}
icon={<Trash />}
variant="minimal-destructive"
size="medium"
/>
</div>
<img src={image.url} alt="" />
<Link to={`/u/${image.submitterUserId}`} className="text-xs">
@@ -63,16 +60,16 @@ function ImageValidator() {
})}
</div>
<Form method="post" className="mt-12">
<input
type="hidden"
name="imageIds"
value={JSON.stringify(data.images.map((img) => img.id))}
/>
<SubmitButton size="big" className="mx-auto" _action="VALIDATE">
All {data.images.length} above ok
</SubmitButton>
</Form>
<ActionButton
schema={validateImageSchema}
action="VALIDATE"
fields={{ imageIds: data.images.map((img) => img.id) }}
formClassName="mt-12"
size="big"
className="mx-auto"
>
All {data.images.length} above ok
</ActionButton>
</>
);
}

View File

@@ -46,7 +46,7 @@ const TRANSLATORS: Array<{
language: "de",
},
{
translators: ["KaiserOfNone", "Mario64iscool2", "Lasky"],
translators: ["KaiserOfNone", "Mario64iscool2", "Lasky", "PedroFlores199"],
language: "es-ES",
},
{

View File

@@ -12,6 +12,8 @@ import type {
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
latestSkillPerSeason,
skillCountsAsSeasonSet,
} from "~/utils/kysely.server";
import { dateToDatabaseTimestamp } from "../../utils/dates";
import * as Seasons from "../mmr/core/Seasons";
@@ -43,22 +45,10 @@ const teamLeaderboardBySeasonQuery = (season: number) =>
db
.selectFrom((eb) =>
eb
.selectFrom((eb) =>
eb
.selectFrom("Skill")
// with a lone max() aggregate SQLite takes the bare columns
// from the row that had the max id
.select(({ fn }) => [
fn.max("Skill.id").as("entryId"),
"Skill.ordinal",
"Skill.matchesCount",
])
.where("Skill.season", "=", season)
.where("Skill.identifier", "is not", null)
.groupBy("Skill.identifier")
.as("LatestOfTeam"),
.selectFrom(
latestSkillPerSeason({ season, by: "identifier" }).as("LatestOfTeam"),
)
.select(["LatestOfTeam.entryId", "LatestOfTeam.ordinal"])
.select(["LatestOfTeam.latestId as entryId", "LatestOfTeam.ordinal"])
.where(
"LatestOfTeam.matchesCount",
">=",
@@ -276,21 +266,7 @@ export async function findSeasonsParticipatedInByUserId(userId: number) {
.selectFrom("Skill")
.select("season")
.where("userId", "=", userId)
.where(({ or, eb, exists, selectFrom }) =>
or([
eb("groupMatchId", "is not", null),
exists(
selectFrom("TournamentResult")
.select("TournamentResult.userId")
.whereRef(
"TournamentResult.tournamentId",
"=",
"Skill.tournamentId",
)
.where("TournamentResult.userId", "=", userId),
),
]),
)
.where((eb) => skillCountsAsSeasonSet(eb, userId))
.groupBy("season")
.orderBy("season", "desc")
.execute();
@@ -397,26 +373,11 @@ export type UserSPLeaderboardItem = Awaited<
export async function findUserSPLeaderboard(season: number) {
const rows = await db
.selectFrom((eb) =>
eb
.selectFrom("Skill")
// with a lone max() aggregate SQLite takes the bare columns from the
// row that had the max id
.select(({ fn }) => [
fn.max("Skill.id").as("entryId"),
"Skill.ordinal",
"Skill.matchesCount",
"Skill.userId",
])
.where("Skill.season", "=", season)
.where("Skill.userId", "is not", null)
.groupBy("Skill.userId")
.as("Latest"),
)
.selectFrom(latestSkillPerSeason({ season, by: "userId" }).as("Latest"))
.innerJoin("User", "User.id", "Latest.userId")
.select((eb) => [
...commonUserSelect(eb),
"Latest.entryId",
"Latest.latestId as entryId",
"Latest.ordinal",
"User.plusSkippedForSeasonNth",
])

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from "vitest";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { DEFAULT_LEADERBOARD_MAX_SIZE } from "../leaderboards-constants";
import {
filterByWeaponCategory,
ownEntryPeek,
shownUserLeaderboard,
type UserLeaderboardWithAdditionsItem,
} from "./leaderboards.server";
const FIRST_TIED_RANK = DEFAULT_LEADERBOARD_MAX_SIZE - 2;
/**
* Leaderboard where five players are tied in SP across the shown-size cutoff:
* indices 497–501 (0-based) all share placementRank 498, like players who
* finished the season having played every match in the same stack do.
*/
const leaderboardWithTieAcrossCutoff = () =>
Array.from({ length: DEFAULT_LEADERBOARD_MAX_SIZE + 2 }, (_, i) => {
const placementRank = i >= FIRST_TIED_RANK - 1 ? FIRST_TIED_RANK : i + 1;
return {
id: i + 1,
placementRank,
power: 2100 - placementRank,
} as unknown as UserLeaderboardWithAdditionsItem;
});
describe("shownUserLeaderboard & ownEntryPeek", () => {
test("player tied across the cutoff is visible in the table or via own entry peek", async () => {
const leaderboard = leaderboardWithTieAcrossCutoff();
const cutOffUserId = DEFAULT_LEADERBOARD_MAX_SIZE + 2;
const shownIds = shownUserLeaderboard(leaderboard).map((entry) => entry.id);
if (!shownIds.includes(cutOffUserId)) {
const peek = await ownEntryPeek({
leaderboard,
userId: cutOffUserId,
season: 1,
});
expect(peek).not.toBeNull();
}
});
test("shows every tied player at the cutoff rank", () => {
const leaderboard = leaderboardWithTieAcrossCutoff();
const shown = shownUserLeaderboard(leaderboard);
expect(shown).toHaveLength(leaderboard.length);
});
test("cuts players ranked below the max size", () => {
const leaderboard = Array.from(
{ length: DEFAULT_LEADERBOARD_MAX_SIZE + 2 },
(_, i) =>
({
id: i + 1,
placementRank: i + 1,
power: 2100 - i,
}) as unknown as UserLeaderboardWithAdditionsItem,
);
const shown = shownUserLeaderboard(leaderboard);
expect(shown).toHaveLength(DEFAULT_LEADERBOARD_MAX_SIZE);
});
});
describe("filterByWeaponCategory", () => {
const SPLOOSH_O_MATIC: MainWeaponId = 0;
const SPLATTERSHOT: MainWeaponId = 40;
const LUNA_BLASTER: MainWeaponId = 200;
test("keeps a Sploosh-o-matic (weapon id 0) player on the shooters leaderboard", () => {
const entries = [
{ id: 1, weaponSplId: SPLOOSH_O_MATIC },
{ id: 2, weaponSplId: SPLATTERSHOT },
{ id: 3, weaponSplId: LUNA_BLASTER },
{ id: 4, weaponSplId: undefined },
];
const filtered = filterByWeaponCategory(entries, "SHOOTERS");
expect(filtered.map((entry) => entry.id)).toEqual([1, 2]);
});
});

View File

@@ -141,7 +141,23 @@ export function filterByWeaponCategory<
);
return entries.filter(
(entry) => entry.weaponSplId && weaponIdsOfCategory.has(entry.weaponSplId),
(entry) =>
typeof entry.weaponSplId === "number" &&
weaponIdsOfCategory.has(entry.weaponSplId),
);
}
/**
* The entries of the full user leaderboard that are visible on the leaderboard
* page. Cut by placement rank instead of entry count so that players tied
* across the cutoff are all shown; {@link ownEntryPeek} covers exactly the
* entries this leaves out.
*/
export function shownUserLeaderboard(
leaderboard: UserLeaderboardWithAdditionsItem[],
) {
return leaderboard.filter(
(entry) => entry.placementRank <= DEFAULT_LEADERBOARD_MAX_SIZE,
);
}

View File

@@ -13,11 +13,9 @@ import {
cachedFullUserLeaderboard,
filterByWeaponCategory,
ownEntryPeek,
shownUserLeaderboard,
} from "../core/leaderboards.server";
import {
DEFAULT_LEADERBOARD_MAX_SIZE,
WEAPON_LEADERBOARD_MAX_SIZE,
} from "../leaderboards-constants";
import { WEAPON_LEADERBOARD_MAX_SIZE } from "../leaderboards-constants";
import { leaderboardsSearchParams } from "../leaderboards-search-params";
export const loader = async ({ url }: LoaderFunctionArgs) => {
@@ -32,10 +30,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
? await cachedFullUserLeaderboard(season)
: null;
const userLeaderboard = fullUserLeaderboard?.slice(
0,
DEFAULT_LEADERBOARD_MAX_SIZE,
);
const userLeaderboard = fullUserLeaderboard
? shownUserLeaderboard(fullUserLeaderboard)
: undefined;
const teamLeaderboard =
type === "TEAM" || type === "TEAM-ALL"

View File

@@ -0,0 +1,51 @@
import { createBrowserRouter, RouterProvider } from "react-router";
import { afterEach, describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import { leaderboardsSearchParams } from "../leaderboards-search-params";
import LeaderboardsPage from "./leaderboards";
vi.mock("../loaders/leaderboards.server", () => ({ loader: vi.fn() }));
const xpLeaderboardData = {
userLeaderboard: undefined,
ownEntryPeek: null,
teamLeaderboard: null,
xpLeaderboard: [],
season: 9,
};
function renderPage() {
const router = createBrowserRouter([
{
path: "*",
element: <LeaderboardsPage />,
loader: () => xpLeaderboardData,
},
]);
return render(<RouterProvider router={router} />);
}
afterEach(() => {
window.history.replaceState(null, "", window.location.pathname);
});
describe("LeaderboardsPage", () => {
test("type select shows the selected XP leaderboard instead of the default SP leaderboard", async () => {
window.history.replaceState(
null,
"",
leaderboardsSearchParams.href(window.location.pathname, {
type: "XP-ALL",
season: null,
}),
);
const screen = await renderPage();
const select = screen.getByRole("combobox");
await expect.element(select).toBeVisible();
expect((select.element() as HTMLSelectElement).value).toBe("XP-ALL");
});
});

View File

@@ -71,11 +71,15 @@ export default function LeaderboardsPage() {
type: string;
}) => `${type};${season}`;
// XP leaderboards are not tied to a season, so their option values have no
// season suffix and the select value must match
const selectValue = () =>
seasonPlusTypeToKey({
season: data.season,
type: params.type,
});
params.type.startsWith("XP")
? params.type
: seasonPlusTypeToKey({
season: data.season,
type: params.type,
});
const showTopTen = Boolean(
seasonHasTopTen(data.season) &&

View File

@@ -1,15 +1,14 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { _action, id } from "~/utils/zod";
import * as LFGRepository from "../LFGRepository.server";
import { lfgActionSchema } from "../lfg-schemas";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema,
schema: lfgActionSchema,
});
const posts = await LFGRepository.findAllPosts(user);
@@ -33,14 +32,3 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return null;
};
const schema = z.union([
z.object({
_action: _action("DELETE_POST"),
id,
}),
z.object({
_action: _action("BUMP_POST"),
id,
}),
]);

View File

@@ -0,0 +1,55 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { LFGLoaderPost } from "../routes/lfg";
import { filterPosts } from "./filtering";
const postOfType = (type: LFGLoaderPost["type"]) =>
({
type,
author: { weaponPool: [] },
team: null,
}) as unknown as LFGLoaderPost;
describe("filterPosts", () => {
test("a weapon filter with no weapons selected shows every post", () => {
const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")];
const filtered = filterPosts(
posts,
[{ _tag: "Weapon", weaponSplIds: [] }],
new Map(),
);
expect(filtered).toHaveLength(2);
});
describe("timezone filter", () => {
afterEach(() => {
vi.restoreAllMocks();
});
const stubUserTimezone = (timeZone: string) => {
vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({
resolvedOptions: () => ({ timeZone }),
} as unknown as Intl.DateTimeFormat);
};
test("shows a post from across the date line when local clock times match", () => {
// Pacific/Kiritimati (UTC+14) and Pacific/Honolulu (UTC-10) share the
// exact same local clock time year round
stubUserTimezone("Pacific/Honolulu");
const post = {
...postOfType("PLAYER_FOR_TEAM"),
timezone: "Pacific/Kiritimati",
};
const filtered = filterPosts(
[post],
[{ _tag: "Timezone", maxHourDifference: 3 }],
new Map(),
);
expect(filtered).toHaveLength(1);
});
});
});

View File

@@ -31,7 +31,7 @@ function filterMatchesPost(
if (post.type === "COACH_FOR_TEAM") {
// not visible in the UI
if (
filter._tag === "Weapon" ||
(filter._tag === "Weapon" && filter.weaponSplIds.length > 0) ||
filter._tag === "MaxTier" ||
filter._tag === "MinTier"
) {

View File

@@ -0,0 +1,22 @@
import { describe, expect, test } from "vitest";
import { hourDifferenceBetweenTimezones } from "./timezone";
describe("hourDifferenceBetweenTimezones", () => {
// Pacific/Kiritimati is UTC+14 and Pacific/Honolulu is UTC-10, neither
// observes DST -> their local clocks show the exact same time year round
test("timezones with identical local clock time across the date line have zero difference", () => {
expect(
hourDifferenceBetweenTimezones("Pacific/Kiritimati", "Pacific/Honolulu"),
).toBe(0);
});
// Asia/Tokyo (UTC+9) and Pacific/Honolulu (UTC-10) are 19 hours apart on
// paper but their local clocks only differ by 5 hours
test("difference is never more than 12 hours in either direction", () => {
expect(
Math.abs(
hourDifferenceBetweenTimezones("Asia/Tokyo", "Pacific/Honolulu"),
),
).toBeLessThanOrEqual(12);
});
});

View File

@@ -6,7 +6,10 @@ export function hourDifferenceBetweenTimezones(
) {
const offset1 = getTimezoneOffset(timezone1);
const offset2 = getTimezoneOffset(timezone2);
return (offset1 - offset2) / 60;
const rawDifference = (offset1 - offset2) / 60;
// wrap to [-12, 12] so timezones across the date line compare by local clock time
return ((((rawDifference + 12) % 24) + 24) % 24) - 12;
}
// https://stackoverflow.com/a/29268535

View File

@@ -7,6 +7,7 @@ import {
selectDynamicOptional,
textArea,
} from "~/form/fields";
import { _action, id } from "~/utils/zod";
import { LFG, TIMEZONES } from "./lfg-constants";
export const lfgNewSchema = z
@@ -37,3 +38,14 @@ export const lfgNewSchema = z
message: "Invalid timezone",
path: ["timezone"],
});
export const lfgActionSchema = z.union([
z.object({
_action: _action("DELETE_POST"),
id,
}),
z.object({
_action: _action("BUMP_POST"),
id,
}),
]);

View File

@@ -14,12 +14,16 @@ const plusTierFilter: LFGFilter = { _tag: "PlusTier", tier: 1 };
const maxTierFilter: LFGFilter = { _tag: "MaxTier", tier: "GOLD" };
const minTierFilter: LFGFilter = { _tag: "MinTier", tier: "BRONZE" };
// the filter LFGAddFilterButton inserts when the user picks "Weapon"
const emptyWeaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [] };
describe("lfgSearchParams", () => {
it("round-trips", () => {
assertRoundTrips(lfgSearchParams, {
q: [
[],
[weaponFilter],
[emptyWeaponFilter],
[typeFilter],
[timezoneFilter],
[languageFilter],

View File

@@ -92,15 +92,16 @@ export function filterToSmallStr(filter: LFGFilter): string {
export function smallStrToFilter(s: string): LFGFilter | null {
const [tag, val] = s.split(".");
if (!tag || !val) return null;
if (!tag || val === undefined) return null;
switch (tag) {
case "w": {
// an empty weapon filter is valid, it's what the add filter button inserts
const weaponIds = val
.split(",")
.filter(Boolean)
.map((x) => Number.parseInt(x, 10) as MainWeaponId)
.filter((x) => x !== null && x !== undefined);
if (weaponIds.length === 0) return null;
.filter((x) => !Number.isNaN(x));
return {
_tag: "Weapon",
weaponSplIds: weaponIds,

View File

@@ -3,10 +3,10 @@ import { add, sub } from "date-fns";
import React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Alert } from "~/components/Alert";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useSearchParam } from "~/modules/search-params/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
@@ -20,6 +20,7 @@ import { LFGFilters } from "../components/LFGFilters";
import { LFGPost } from "../components/LFGPost";
import { filterPosts } from "../core/filtering";
import { LFG } from "../lfg-constants";
import { lfgActionSchema } from "../lfg-schemas";
import { lfgSearchParams } from "../lfg-search-params";
import { loader } from "../loaders/lfg.server";
import styles from "./lfg.module.css";
@@ -117,17 +118,21 @@ export default function LFGPage() {
function PostExpiryAlert({ postId }: { postId: number }) {
const { t } = useTranslation(["common", "lfg"]);
const fetcher = useFetcher();
return (
<Alert variation="WARNING">
<fetcher.Form method="post" className="stack md horizontal items-center">
<input type="hidden" name="id" value={postId} />
<div className="stack md horizontal items-center">
{t("lfg:expiring")}{" "}
<SubmitButton _action="BUMP_POST" variant="outlined" size="small">
<ActionButton
schema={lfgActionSchema}
action="BUMP_POST"
fields={{ id: postId }}
variant="outlined"
size="small"
>
{t("common:actions.clickHere")}
</SubmitButton>
</fetcher.Form>
</ActionButton>
</div>
</Alert>
);
}

View File

@@ -118,6 +118,16 @@ describe("MapList.generate()", () => {
expect(maps[3].mode).toBe("SZ");
});
it("guarantees a must-include even when the pattern's only ANY slot is in the back half", () => {
for (let i = 0; i < 10; i++) {
const gen = initGenerator();
const maps = gen.next({ amount: 3, pattern: "[RM!]SZTC*" }).value;
expect(maps).toHaveLength(3);
expect(maps.map((map) => map.mode)).toContain("RM");
}
});
it("follows a one mode only pattern", () => {
const gen = initGenerator();
const maps = gen.next({ amount: 3, pattern: "SZ" }).value;
@@ -186,6 +196,13 @@ describe("MapList.generate()", () => {
expect(maps[2].mode, "missign TC (required by pattern)").toBe("TC");
});
it("places a non-guaranteed must-include when the pattern has no flexible slots but more maps than pattern parts", () => {
const gen = initGenerator();
const maps = gen.next({ amount: 5, pattern: "[RM]SZTC" }).value;
expect(maps.map((map) => map.mode)).toContain("RM");
});
it("handles a conflict between pattern and must include", () => {
const gen = initGenerator();
const maps = gen.next({ amount: 1, pattern: "TW[SZ]" }).value;

View File

@@ -340,43 +340,67 @@ function modifyModeOrderByPattern(
modesConsumed++;
}
const mustIncludePlacedIndices = new Set<number>();
if (pattern.mustInclude) {
const flexibleIndices = expandedPattern.flatMap((part, idx) =>
part === undefined || part === "ANY" ? [idx] : [],
);
// with no flexible slots a must include may still claim slots from the
// pattern's repeats, but never from its first cycle (pattern has priority)
const overridableIndices =
flexibleIndices.length > 0
? flexibleIndices
: expandedPattern.flatMap((_, idx) =>
idx >= pattern.pattern.length ? [idx] : [],
);
for (const { mode, isGuaranteed } of pattern.mustInclude) {
// impossible must include, mode is not in the pool
if (!modeOrder.includes(mode)) continue;
let possibleIndices = expandedPattern.every((part) => part !== "ANY")
? // inflexible pattern fallback
expandedPattern.map((_, idx) => idx)
: expandedPattern.flatMap((part, idx) => (part === "ANY" ? [idx] : []));
let possibleIndices = overridableIndices;
if (isGuaranteed) {
const guaranteedPositions = Math.ceil(amount / 2);
possibleIndices = possibleIndices.filter(
const frontIndices = possibleIndices.filter(
(idx) => idx < guaranteedPositions,
);
// an unsatisfiable guaranteed position degrades to any allowed slot
if (frontIndices.length > 0) {
possibleIndices = frontIndices;
}
}
const isAlreadyIncluded = result.includes(mode);
// "good spot" means a spot where the pattern allows ANY mode
const isInGoodSpot = possibleIndices.includes(result.indexOf(mode));
// nothing the must include is allowed to claim, pattern wins entirely
if (possibleIndices.length === 0) continue;
const currentIndex = result.indexOf(mode);
const isAlreadyIncluded = currentIndex !== -1;
// "good spot" means a slot the must include may claim, or one the
// pattern itself pins the mode to (relocating from there is pointless
// as the pattern gets stamped back over fixed slots)
const isInGoodSpot =
possibleIndices.includes(currentIndex) ||
(!isGuaranteed && expandedPattern[currentIndex] === mode);
if (!isAlreadyIncluded) {
const randomIndex = R.sample(possibleIndices, 1)[0];
invariant(typeof randomIndex === "number");
result[randomIndex] = mode;
mustIncludePlacedIndices.add(randomIndex);
} else if (!isInGoodSpot) {
const currentIndex = result.indexOf(mode);
const targetIndex = R.sample(
possibleIndices.filter((idx) => idx !== currentIndex),
1,
)[0];
const swapTargets = possibleIndices.filter(
(idx) => idx !== currentIndex,
);
if (swapTargets.length === 0) continue;
const targetIndex = R.sample(swapTargets, 1)[0];
invariant(typeof targetIndex === "number");
[result[currentIndex], result[targetIndex]] = [
result[targetIndex],
result[currentIndex],
];
mustIncludePlacedIndices.add(targetIndex);
}
}
}
@@ -387,6 +411,7 @@ function modifyModeOrderByPattern(
for (const [idx, mode] of expandedPattern.entries()) {
if (mode === "ANY") continue;
if (mustIncludePlacedIndices.has(idx)) continue;
if (modeOrder.includes(mode)) {
result[idx] = mode;

View File

@@ -80,7 +80,7 @@ export default function Planner() {
const { t, i18n } = useTranslation(["common"]);
const { htmlThemeClass } = useTheme();
const isWide = i18n.language === "fr";
const isWide = i18n.language.startsWith("fr");
const [editor, setEditor] = React.useState<Editor | null>(null);
const [imgOutlined, setImgOutlined] = React.useState(false);
@@ -770,20 +770,20 @@ const ourLanguageToTldrawLanguageMap: Record<LanguageCode, string> = {
"es-US": "es",
"es-ES": "es",
ko: "ko-kr",
nl: "en",
zh: "zh-ch",
he: "he",
nl: "nl",
zh: "zh-cn",
"fr-CA": "fr",
"fr-EU": "fr",
"pt-BR": "pt-br",
// map to itself
da: "da",
de: "de",
en: "en",
"fr-CA": "fr-CA",
"fr-EU": "fr-EU",
he: "he",
it: "it",
ja: "ja",
ru: "ru",
pl: "pl",
"pt-BR": "pt-br",
};
function ourLanguageToTldrawLanguage(ourLanguageUserSelected: string) {
for (const [ourLanguage, tldrawLanguage] of Object.entries(

View File

@@ -2,6 +2,7 @@ import { sql, type Transaction } from "kysely";
import { ordinal } from "openskill";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { latestSkillPerSeason } from "~/utils/kysely.server";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants";
import type { SkillTeamIdentifier } from "./mmr-utils";
@@ -71,28 +72,8 @@ export async function findCurrentTeamSkills({
* Ordinals of the season's latest skill of every user who has one, best first.
*/
export async function findOrderedUserOrdinalsBySeason(season: number) {
// The latest row per user is picked via SQLite's bare column rule: with a `max()`
// aggregate the other selected columns come from the row that produced the max.
// A self-join against a `max(id)` subquery is avoided because it lets the planner
// pick a nested-loop plan when it misjudges the season's row count (e.g. a freshly
// started season whose stats are dwarfed by older seasons), which made this query
// take ~12s. This form is plan-stable regardless of stats: a single grouped scan of
// the `skill_season_user_id_leaderboard` covering index, no temp b-tree per user.
return db
.selectFrom(
db
.selectFrom("Skill")
.select((eb) => [
"ordinal",
"matchesCount",
"userId",
eb.fn.max("id").as("latestId"),
])
.where("season", "=", season)
.where("userId", "is not", null)
.groupBy("userId")
.as("latest"),
)
.selectFrom(latestSkillPerSeason({ season, by: "userId" }).as("latest"))
.select(["ordinal", "matchesCount", "userId"])
.orderBy("ordinal", "desc")
.execute();

View File

@@ -58,5 +58,8 @@ export const TIERS_BEFORE_LEVIATHAN = [
export type TierName = (typeof TIERS)[number]["name"];
/** `Skill.season` of the dummy skill inserted to close a canceled match without a skill change. */
export const CANCELED_MATCH_SEASON = -1;
export const USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 200;
export const TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 100;

View File

@@ -93,6 +93,37 @@ describe("notify()", () => {
expect(user6Notifications).toHaveLength(1);
});
test("identical notification is delivered again when repeated a day later", async () => {
vi.useFakeTimers();
try {
await notify({
userIds: [users.id(5)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
},
});
vi.advanceTimersByTime(24 * 60 * 60 * 1000);
await notify({
userIds: [users.id(5)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
},
});
} finally {
vi.useRealTimers();
}
const notifications = await NotificationRepository.findByUserId(
users.id(5),
);
expect(notifications).toHaveLength(2);
});
test("user ID order doesn't affect deduplication", async () => {
await notify({
userIds: [users.id(7), users.id(8), users.id(9)],

View File

@@ -99,13 +99,17 @@ export async function notify({
}
}
const sentNotifications = new Set<string>();
const SENT_NOTIFICATION_TTL_MS = 1000 * 60 * 60;
const sentNotifications = new Map<string, number>();
export function clearSentNotificationsForTesting() {
sentNotifications.clear();
}
// deduplicates notifications as a failsafe & anti-abuse mechanism
// deduplicates notifications as a failsafe & anti-abuse mechanism; entries
// expire so a legitimately repeated identical notification (e.g. the same team
// requesting a scrim again weeks later) still gets delivered
function isNotificationAlreadySent(
notification: Notification,
userIds: Array<number>,
@@ -122,10 +126,11 @@ function isNotificationAlreadySent(
const sortedUserIds = [...userIds].sort((a, b) => a - b).join(",");
const key = `${notification.type}-${JSON.stringify(notification.meta)}-${sortedUserIds}`;
if (sentNotifications.has(key)) {
const sentAt = sentNotifications.get(key);
if (sentAt && Date.now() - sentAt < SENT_NOTIFICATION_TTL_MS) {
return true;
}
sentNotifications.add(key);
sentNotifications.set(key, Date.now());
if (sentNotifications.size > 10_000) {
sentNotifications.clear();

View File

@@ -0,0 +1,86 @@
import { createBrowserRouter, RouterProvider } from "react-router";
import { describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import type * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
import { PlusSuggestionComments } from "./plus.suggestions";
vi.mock("../actions/plus.suggestions.server", () => ({ action: vi.fn() }));
vi.mock("../loaders/plus.suggestions.server", () => ({ loader: vi.fn() }));
const AUTHOR = {
id: 5,
username: "Suggester",
discordId: "1005",
discordAvatar: null,
customUrl: null,
customAvatarUrl: null,
};
const suggestedUser = (id: number, username: string) => ({
id,
username,
discordId: String(1000 + id),
discordAvatar: null,
customUrl: null,
customAvatarUrl: null,
bio: null,
plusTier: null,
});
const NOW_TIMESTAMP = Math.floor(Date.now() / 1000);
const suggestionOf = (
suggested: ReturnType<typeof suggestedUser>,
entryId: number,
): PlusSuggestionRepository.FindAllByMonthItem => ({
tier: 2,
suggested,
entries: [
{
id: entryId,
text: "Great player, deserves a spot",
createdAtRelative: "3 days ago",
createdAt: NOW_TIMESTAMP,
updatedAt: null,
updatedAtRelative: null,
author: AUTHOR,
},
],
});
describe("PlusSuggestionComments", () => {
test("deleting the suggestion itself warns about the suggestion, not a comment", async () => {
const suggestions = [
suggestionOf(suggestedUser(10, "Suggested One"), 100),
suggestionOf(suggestedUser(11, "Suggested Two"), 101),
];
const router = createBrowserRouter([
{
path: "*",
element: (
<PlusSuggestionComments
suggestion={suggestions[0]}
deleteButtonArgs={{
suggested: suggestions[0].suggested,
user: AUTHOR,
tier: "2",
suggestions,
}}
defaultOpen
/>
),
},
]);
const screen = await render(<RouterProvider router={router} />);
await screen.getByLabelText("Delete comment").click();
await expect
.element(
screen.getByText("Delete your suggestion of Suggested One to +2?"),
)
.toBeVisible();
});
});

View File

@@ -40,6 +40,7 @@ import {
canDeleteComment,
canEditSuggestion,
canSuggestNewUser,
isFirstSuggestion,
} from "../plus-suggestions-utils";
export { action, loader };
@@ -329,9 +330,10 @@ export function PlusSuggestionComments({
suggestionId={entry.id}
tier={deleteButtonArgs.tier}
suggestedUsername={deleteButtonArgs.suggested.username}
isFirstSuggestion={
deleteButtonArgs.suggestions.length === 1
}
isFirstSuggestion={isFirstSuggestion({
suggestionId: entry.id,
suggestions: deleteButtonArgs.suggestions,
})}
/>
) : null}
</div>

View File

@@ -104,7 +104,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
scrimPostId: data.scrimPostId,
teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
message: data.message,
startsAt: data.at ? dateToDatabaseTimestamp(data.at) : null,
startsAt:
post.rangeEndsAt && data.at
? dateToDatabaseTimestamp(data.at)
: null,
users: (
await usersListForPost({ authorId: user.id, from: data.from })
).map((userId) => ({

View File

@@ -12,7 +12,8 @@ import {
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Form, Link } from "react-router";
import { Link } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
@@ -32,6 +33,7 @@ import type { ModeShort } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { scrimPage, tournamentRegisterPage } from "~/utils/urls";
import { scrimsActionSchema } from "../scrims-schemas";
import { scrimsSearchParams } from "../scrims-search-params";
import type { ScrimPost, ScrimPostRequest } from "../scrims-types";
import { formatFlexTimeDisplay } from "../scrims-utils";
@@ -502,21 +504,15 @@ function ScrimActionButtons({
/>
</div>
) : null}
<Form method="post">
<input
type="hidden"
name="scrimPostRequestId"
value={userRequest.id}
/>
<input type="hidden" name="_action" value="CANCEL_REQUEST" />
<SendouButton
type="submit"
variant="destructive"
icon={<Trash />}
>
{t("common:actions.cancel")}
</SendouButton>
</Form>
<ActionButton
schema={scrimsActionSchema}
action="CANCEL_REQUEST"
fields={{ scrimPostRequestId: userRequest.id }}
variant="destructive"
icon={<Trash />}
>
{t("common:actions.cancel")}
</ActionButton>
</div>
</SendouDialog>
) : null}

View File

@@ -1,14 +1,16 @@
import { MapPin, Repeat, Undo2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
import { useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouTabPanel } from "~/components/elements/Tabs";
import { MatchActionTab } from "~/components/match-page/MatchActionTab";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { useUser } from "~/features/auth/core/user";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import * as Scrim from "../core/Scrim";
import * as ScrimMapByMap from "../core/ScrimMapByMap";
import type { loader } from "../loaders/scrims.$id.server";
import { scrimIdActionSchema } from "../scrims-schemas";
import type { ScrimSide } from "../scrims-types";
import { PickMapDialog } from "./PickMapDialog";
import { ScrimMapListManager } from "./ScrimMapListManager";
@@ -51,7 +53,7 @@ function NotParticipantSection() {
function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const reportMap = useActionSubmit(scrimIdActionSchema);
const map = data.mapByMap!.currentMap!;
const acceptedRequest = data.post.requests.find((r) => r.isAccepted)!;
@@ -75,16 +77,12 @@ function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
stageId={map.stageId}
mode={map.mode}
withKo={false}
isSubmitting={fetcher.state !== "idle"}
isSubmitting={reportMap.state !== "idle"}
onSubmit={({ winnerId }) => {
fetcher.submit(
{
_action: "REPORT_MAP",
mapId: String(map.id),
winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO",
},
{ method: "post" },
);
reportMap.submit("REPORT_MAP", {
mapId: map.id,
winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO",
});
}}
actionButtons={<MapActionButtons />}
secondaryAction={<ScrimMapListManager viewerSide={viewerSide} />}
@@ -95,8 +93,8 @@ function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
function MapActionButtons() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
const undoFetcher = useFetcher();
const replayFetcher = useFetcher();
const undoMap = useActionSubmit(scrimIdActionSchema);
const replayMap = useActionSubmit(scrimIdActionSchema);
const maps = data.mapByMap?.maps ?? [];
const currentMap = data.mapByMap?.currentMap;
@@ -111,10 +109,10 @@ function MapActionButtons() {
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
isPending={undoFetcher.state !== "idle"}
isPending={undoMap.state !== "idle"}
isDisabled={!undoAllowed}
onPress={() => {
undoFetcher.submit({ _action: "UNDO_MAP" }, { method: "post" });
undoMap.submit("UNDO_MAP");
}}
>
{t("scrims:mapByMap.undo")}
@@ -124,10 +122,10 @@ function MapActionButtons() {
variant="minimal"
size="miniscule"
icon={<Repeat size={16} />}
isPending={replayFetcher.state !== "idle"}
isPending={replayMap.state !== "idle"}
isDisabled={!replayAllowed}
onPress={() => {
replayFetcher.submit({ _action: "REPLAY_MAP" }, { method: "post" });
replayMap.submit("REPLAY_MAP");
}}
>
{t("scrims:mapByMap.replay")}

View File

@@ -0,0 +1,62 @@
import { add } from "date-fns";
import { beforeEach, describe, expect, test } from "vitest";
import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { toDBBoolean } from "~/utils/sql";
import { wrappedAction } from "~/utils/Test";
import { action } from "../actions/scrims.server";
import type { scrimsActionSchema } from "../scrims-schemas";
const scrimsAction = wrappedAction<typeof scrimsActionSchema>({
action,
isJsonSubmission: true,
});
describe("Scrims NEW_REQUEST action", () => {
let postId: number;
let requesterPickupUserIds: number[];
const postStartsAt = add(new Date(), { days: 1 });
beforeEach(async () => {
const postOwner = await UserFactory.createRegular();
const postMembers = await UserFactory.createMany(3);
await UserFactory.createAdmin();
requesterPickupUserIds = (await UserFactory.createMany(3)).map(
(user) => user.id,
);
const post = await ScrimPostFactory.create({
startsAt: dateToDatabaseTimestamp(postStartsAt),
users: [
{ userId: postOwner.id, isOwner: toDBBoolean(true) },
...postMembers.map((member) => ({
userId: member.id,
isOwner: toDBBoolean(false),
})),
],
});
postId = post.id;
});
test("doesn't store a requester-chosen time for a post without a flexible time range", async () => {
await scrimsAction(
{
_action: "NEW_REQUEST",
scrimPostId: postId,
from: { mode: "PICKUP", users: requesterPickupUserIds },
message: null,
at: add(postStartsAt, { hours: 5 }),
},
{ user: "admin" },
);
const request = await db
.selectFrom("ScrimPostRequest")
.selectAll()
.executeTakeFirstOrThrow();
expect(request.startsAt).toBeNull();
});
});

View File

@@ -11,6 +11,7 @@ import type { DB, Tables } from "~/db/tables";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import {
commonUserJsonObject,
latestSkillPerSeason,
tournamentLogoWithDefault,
} from "~/utils/kysely.server";
@@ -380,16 +381,11 @@ export async function findSeasonTournamentRunsByUserId({
tournamentLogoWithDefault(eb).as("logoUrl"),
eb
.selectFrom(
eb
.selectFrom("Skill as TopEightSkill")
.select((seb) => [
"TopEightSkill.ordinal",
// bare column with max(): the ordinal comes from the season's last skill row of that user
seb.fn.max("TopEightSkill.id").as("latestId"),
])
.where("TopEightSkill.season", "=", season)
.where("TopEightSkill.userId", "in", (ieb) =>
ieb
latestSkillPerSeason({ season, by: "userId" })
.where(
"Skill.userId",
"in",
eb
.selectFrom("TournamentResult as TopEightResult")
.select("TopEightResult.userId")
.whereRef("TopEightResult.tournamentId", "=", "Tournament.id")
@@ -399,7 +395,6 @@ export async function findSeasonTournamentRunsByUserId({
TOURNAMENT_FIELD_STRENGTH_PLACEMENT,
),
)
.groupBy("TopEightSkill.userId")
.as("TopEightLatestSkill"),
)
.select(({ fn }) =>

View File

@@ -359,6 +359,42 @@ describe("finalizeMatch", () => {
});
});
describe("undoMatchReport", () => {
// intended: the group stays INACTIVE after an undo so it can't re-enter the
// queue while the disagreement is unresolved; the teams keep playing and
// staff resolve the match when available
test("keeps the reporter's group INACTIVE after undoing a set-ending report", async () => {
const setup = await setupMatch();
let reportedCount = 0;
let result = await SQMatchRepository.reportMapWinner({
matchId: setup.match.id,
winnerId: setup.alphaGroupId,
reportedByUserId: setup.alphaMembers[0].id,
reportedCount,
});
while (result.status === "MAP_REPORTED") {
reportedCount++;
result = await SQMatchRepository.reportMapWinner({
matchId: setup.match.id,
winnerId: setup.alphaGroupId,
reportedByUserId: setup.alphaMembers[0].id,
reportedCount,
});
}
expect(result.status).toBe("MATCH_REPORTED");
expect((await fetchGroup(setup.alphaGroupId))?.status).toBe("INACTIVE");
const undoResult = await SQMatchRepository.undoMatchReport({
matchId: setup.match.id,
requestedByUserId: setup.alphaMembers[0].id,
});
expect(undoResult.status).toBe("SUCCESS");
expect((await fetchGroup(setup.alphaGroupId))?.status).toBe("INACTIVE");
});
});
describe("findCancelNominationCountsByUserIds", () => {
test("counts distinct finalized cancellations within season and calendar year", async () => {
const season = Seasons.currentOrPrevious()!;

View File

@@ -7,6 +7,7 @@ import type { DB } from "~/db/tables";
import type { ParsedMemento } from "~/db/tables-json";
import { actorId } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { CANCELED_MATCH_SEASON } from "~/features/mmr/mmr-constants";
import { serializeMaplistSource } from "~/modules/tournament-map-list-generator/source";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
import { mostPopularArrayElement } from "~/utils/arrays";
@@ -17,6 +18,7 @@ import {
commonUserSelect,
concatUserSubmittedImagePrefix,
matchProfileWeapons,
skillCountsAsSeasonSet,
tournamentLogoWithDefault,
} from "~/utils/kysely.server";
import type { Unpacked } from "~/utils/types";
@@ -69,7 +71,7 @@ export async function findById(id: number) {
selectFrom("Skill")
.select("Skill.id")
.where("Skill.groupMatchId", "=", id)
.where("Skill.season", "=", -1),
.where("Skill.season", "=", CANCELED_MATCH_SEASON),
).as("isCanceled"),
jsonArrayFrom(
eb
@@ -193,21 +195,7 @@ export async function countSeasonResultPagesByUserId({
.select(({ fn }) => [fn.countAll().as("count")])
.where("userId", "=", userId)
.where("season", "=", season)
.where(({ or, eb, exists, selectFrom }) =>
or([
eb("groupMatchId", "is not", null),
exists(
selectFrom("TournamentResult")
.select("TournamentResult.userId")
.whereRef(
"TournamentResult.tournamentId",
"=",
"Skill.tournamentId",
)
.where("TournamentResult.userId", "=", userId),
),
]),
)
.where((eb) => skillCountsAsSeasonSet(eb, userId))
.executeTakeFirstOrThrow();
return Math.ceil((row.count as number) / MATCHES_PER_SEASONS_PAGE);
@@ -331,21 +319,7 @@ export async function findSeasonResultsByUserId({
])
.where("userId", "=", userId)
.where("season", "=", season)
.where(({ or, eb, exists, selectFrom }) =>
or([
eb("groupMatchId", "is not", null),
exists(
selectFrom("TournamentResult")
.select("TournamentResult.userId")
.whereRef(
"TournamentResult.tournamentId",
"=",
"Skill.tournamentId",
)
.where("TournamentResult.userId", "=", userId),
),
]),
)
.where((eb) => skillCountsAsSeasonSet(eb, userId))
.limit(MATCHES_PER_SEASONS_PAGE)
.offset(MATCHES_PER_SEASONS_PAGE * (page - 1))
.orderBy("Skill.id", "desc")
@@ -437,8 +411,7 @@ export async function findSeasonCanceledMatchesByUserId({
.innerJoin("Skill", (join) =>
join
.onRef("GroupMatch.id", "=", "Skill.groupMatchId")
// dummy skills used to close match when it's canceled have season -1
.on("Skill.season", "=", -1),
.on("Skill.season", "=", CANCELED_MATCH_SEASON),
)
.select((eb) => [
"GroupMatch.id",
@@ -544,8 +517,7 @@ export async function findCancelNominationCountsByUserIds({
.innerJoin("Skill", (join) =>
join
.onRef("Skill.groupMatchId", "=", "GroupMatch.id")
// dummy skills used to close match when it's canceled have season -1
.on("Skill.season", "=", -1),
.on("Skill.season", "=", CANCELED_MATCH_SEASON),
)
.select([
"GroupMatchCancelReportPlayer.userId",
@@ -728,7 +700,7 @@ export function lockMatchWithoutSkillChange(
groupMatchId,
identifier: null,
mu: -1,
season: -1,
season: CANCELED_MATCH_SEASON,
sigma: -1,
ordinal: -1,
userId: null,

View File

@@ -203,7 +203,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
trx,
);
if (!RejoinVote.canCastVote(existingVotes, user.id)) {
if (
!RejoinVote.canCastVote(existingVotes, user.id, data.isContinuing)
) {
return null;
}

View File

@@ -1,9 +1,12 @@
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { frontPageSchema } from "~/features/sendouq/q-action-schemas";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { SENDOUQ_PAGE } from "~/utils/urls";
import * as RejoinVote from "../core/RejoinVote";
import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
import { matchSchema } from "../q-match-schemas";
import { RematchVotePanel } from "./RematchVotePanel";
export function MatchmadeRejoinSection({
@@ -62,22 +65,18 @@ export function TrustedRejoinSection({
const viewerRole = viewerGroup.members.find(
(m) => m.id === viewerUserId,
)?.role;
const lookAgainFetcher = useFetcher();
const lookAgain = useActionSubmit(matchSchema);
if (viewerRole === "OWNER") {
return (
<div className="stack md items-center">
<SendouButton
variant="primary"
isPending={lookAgainFetcher.state !== "idle"}
isPending={lookAgain.state !== "idle"}
onPress={() => {
lookAgainFetcher.submit(
{
_action: "LOOK_AGAIN",
previousGroupId: String(viewerGroup.id),
},
{ method: "post" },
);
lookAgain.submit("LOOK_AGAIN", {
previousGroupId: viewerGroup.id,
});
}}
>
{t("q:match.actions.lookAgain")}
@@ -95,7 +94,9 @@ export function TrustedRejoinSection({
function DeclinedSection() {
const { t } = useTranslation(["q"]);
const rejoinFetcher = useFetcher();
const rejoinQueue = useActionSubmit(frontPageSchema, {
action: SENDOUQ_PAGE,
});
return (
<div className="stack md items-center">
<p className="text-lighter text-sm text-center">
@@ -104,12 +105,9 @@ function DeclinedSection() {
<SendouButton
variant="minimal"
className="text-sm font-bold"
isPending={rejoinFetcher.state !== "idle"}
isPending={rejoinQueue.state !== "idle"}
onPress={() => {
rejoinFetcher.submit(
{ _action: "JOIN_QUEUE", direct: "true" },
{ method: "post", action: SENDOUQ_PAGE },
);
rejoinQueue.submit("JOIN_QUEUE", { direct: "true" });
}}
>
{t("q:match.rematch.rejoinQueue")}

View File

@@ -4,8 +4,10 @@ import { type FetcherWithComponents, Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
import * as RejoinVote from "../core/RejoinVote";
import { matchSchema } from "../q-match-schemas";
import styles from "./RematchVotePanel.module.css";
export type RematchVoteMember = {
@@ -30,6 +32,7 @@ export function RematchVotePanel({
fetcher,
}: RematchVotePanelProps) {
const { t } = useTranslation(["q"]);
const { submit } = useActionSubmit(matchSchema, { fetcher });
const isPending = fetcher.state !== "idle";
@@ -98,15 +101,7 @@ export function RematchVotePanel({
variant="primary"
size="small"
isDisabled={isPending || viewerVotedYes}
onPress={() =>
fetcher.submit(
{
_action: "CAST_CONTINUE_VOTE",
isContinuing: "1",
},
{ method: "post" },
)
}
onPress={() => submit("CAST_CONTINUE_VOTE", { isContinuing: true })}
>
{t("q:match.rematch.vote.yes")}
</SendouButton>

Some files were not shown because too many files have changed in this diff Show More