mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Merge remote-tracking branch 'origin/main' into tourney-custom-themes
This commit is contained in:
@@ -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
|
||||
|
||||
113
app/components/ActionButton.tsx
Normal file
113
app/components/ActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
app/components/RelativeTime.browser.test.tsx
Normal file
48
app/components/RelativeTime.browser.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
|
||||
10
app/components/UserLink.module.css
Normal file
10
app/components/UserLink.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
53
app/components/UserLink.tsx
Normal file
53
app/components/UserLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
39
app/components/layout/global-search-persisted.test.ts
Normal file
39
app/components/layout/global-search-persisted.test.ts
Normal 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]"]);
|
||||
});
|
||||
});
|
||||
40
app/components/layout/global-search-persisted.ts
Normal file
40
app/components/layout/global-search-persisted.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
21
app/components/match-page/match-page-schemas.ts
Normal file
21
app/components/match-page/match-page-schemas.ts
Normal 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,
|
||||
]);
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
app/features/api-public/routes/calendar.$year.$week.test.ts
Normal file
34
app/features/api-public/routes/calendar.$year.$week.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
11
app/features/api/api-schemas.ts
Normal file
11
app/features/api/api-schemas.ts
Normal 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"),
|
||||
}),
|
||||
]);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
])
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
58
app/features/build-analyzer/core/ability-points.ts
Normal file
58
app/features/build-analyzer/core/ability-points.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
60
app/features/calendar/loaders/calendar.server.test.ts
Normal file
60
app/features/calendar/loaders/calendar.server.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
49
app/features/calendar/routes/calendar.$id.test.ts
Normal file
49
app/features/calendar/routes/calendar.$id.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
20
app/features/chat/chat-last-read.test.ts
Normal file
20
app/features/chat/chat-last-read.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const TRANSLATORS: Array<{
|
||||
language: "de",
|
||||
},
|
||||
{
|
||||
translators: ["KaiserOfNone", "Mario64iscool2", "Lasky"],
|
||||
translators: ["KaiserOfNone", "Mario64iscool2", "Lasky", "PedroFlores199"],
|
||||
language: "es-ES",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
|
||||
89
app/features/leaderboards/core/leaderboards.server.test.ts
Normal file
89
app/features/leaderboards/core/leaderboards.server.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
55
app/features/lfg/core/filtering.test.ts
Normal file
55
app/features/lfg/core/filtering.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
) {
|
||||
|
||||
22
app/features/lfg/core/timezone.test.ts
Normal file
22
app/features/lfg/core/timezone.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
62
app/features/scrims/routes/scrims.test.ts
Normal file
62
app/features/scrims/routes/scrims.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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()!;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user