mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 04:05:54 -05:00
Simplify comments
This commit is contained in:
@@ -176,28 +176,21 @@ function addAbility({
|
||||
) as BuildAbilitiesTupleWithUnknown;
|
||||
|
||||
if (atRowIndex !== undefined && atAbilityIndex !== undefined) {
|
||||
// Attempt to place the ability at a specific slot since we
|
||||
// were given an atRowIndex and atAbilityIndex
|
||||
if (canPlaceAbilityAtSlot(atRowIndex, atAbilityIndex, ability)) {
|
||||
// Assign this ability to the slot
|
||||
abilitiesClone[atRowIndex][atAbilityIndex] = ability.name;
|
||||
}
|
||||
} else {
|
||||
// Loop through all slots and attempt to place this ability
|
||||
// in the first empty one
|
||||
// place in the first empty valid slot
|
||||
for (const [rowIndex, row] of abilitiesClone.entries()) {
|
||||
for (const [abilityIndex, oldAbility] of row.entries()) {
|
||||
if (oldAbility !== "UNKNOWN") {
|
||||
// Skip any filled slots in this loop until we arrive at an empty one.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!canPlaceAbilityAtSlot(rowIndex, abilityIndex, ability)) {
|
||||
// This ability isn't valid for this slot
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assign this ability to the slot
|
||||
abilitiesClone[rowIndex][abilityIndex] = ability.name;
|
||||
|
||||
return abilitiesClone;
|
||||
@@ -205,6 +198,5 @@ function addAbility({
|
||||
}
|
||||
}
|
||||
|
||||
// no-op if no available slots
|
||||
return abilitiesClone;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ export function Ability({
|
||||
|
||||
const readonly = typeof onClick === "undefined" || ability === "UNKNOWN"; // Force "UNKNOWN" ability icons to be readonly
|
||||
|
||||
// Render an ability as a button only if it is meant to be draggable (i.e., not readonly)
|
||||
const AbilityTag = readonly ? "div" : "button";
|
||||
|
||||
const altText =
|
||||
|
||||
@@ -14,16 +14,15 @@ interface ActionButtonBaseProps<
|
||||
TSchema extends AnySchema,
|
||||
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`. */
|
||||
/** Route's action schema, 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. */
|
||||
/** 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. */
|
||||
/** e.g. to share submitting state between buttons */
|
||||
fetcher?: FetcherWithComponents<unknown>;
|
||||
/** When set, submits only after the user confirms via a dialog. */
|
||||
/** submits only after the user confirms via a dialog */
|
||||
confirm?: {
|
||||
dialogHeading: string;
|
||||
description?: React.ReactNode;
|
||||
|
||||
@@ -65,7 +65,7 @@ interface BuildProps {
|
||||
Partial<
|
||||
Pick<BuildGraphicOwner, "customUrl" | "discordAvatar" | "customAvatarUrl">
|
||||
>;
|
||||
/** Set to false when the page context already shows the owner (e.g. their own builds page) */
|
||||
/** false when the page already shows the owner (e.g. their own builds page) */
|
||||
showOwner?: boolean;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Catcher() {
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
// refresh user data to make sure it's up to date (e.g. cookie might have been removed, let's show the prompt to log back in)
|
||||
// refresh user data so e.g. a removed cookie shows the prompt to log back in
|
||||
const hasRevalidated = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!isRouteErrorResponse(error) || error.status !== 401) return;
|
||||
@@ -144,10 +144,7 @@ export function Catcher() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A client side navigation to an URL matching no route never reaches the server, so the
|
||||
* redirects (normally resolved by `redirectsMiddleware`) are checked here as well.
|
||||
*/
|
||||
/** Client side navigation to an unmatched URL never reaches the server, so `redirectsMiddleware`'s redirects are checked here too. */
|
||||
function PageNotFound() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -49,14 +49,14 @@ export default function Chart({
|
||||
xTicksLimit?: number;
|
||||
yTicksLimit?: number;
|
||||
xAbilityLimit?: number;
|
||||
/** Marks current positions on the curve, each at ability point `x` and stat value `y` (e.g. one per build being compared). */
|
||||
/** Markers on the curve at ability point `x` / stat value `y`, e.g. one per build compared. */
|
||||
highlight?: Array<{ x: number; y: number }>;
|
||||
/** When true, draws dashed guide lines from the hovered point to the x- and y-axes. */
|
||||
/** dashed guide lines from the hovered point to both axes */
|
||||
crosshair?: boolean;
|
||||
}) {
|
||||
const isHydrated = useHydrated();
|
||||
|
||||
// Ref to the Chart.js instance, allows proper cleanup between renders to prevent "Canvas is already in use" errors
|
||||
// cleanup between renders prevents "Canvas is already in use" errors
|
||||
const chartRef = useRef<ChartType<"line"> | null>(null);
|
||||
const chartId = React.useId();
|
||||
// Chart.js re-fires the external tooltip on every redraw; track the last value to skip redundant state updates
|
||||
@@ -74,22 +74,19 @@ export default function Chart({
|
||||
header: string;
|
||||
} | null>(null);
|
||||
|
||||
// Format dates in the tooltip header using the user's locale
|
||||
const { formatter: headerFormatter } = useDateTimeFormat({
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
});
|
||||
|
||||
// Format dates on the xAxis
|
||||
const { formatter: scaleFormatter } = useDateTimeFormat({
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
});
|
||||
|
||||
// Get the chart colors from CSS variables
|
||||
const colors = useThemeColors({
|
||||
// bright "high" variants for the curve lines so they stay legible on the dark chart
|
||||
// "high" variants for the curve lines so they stay legible on the dark chart
|
||||
accentHigh: "--color-text-accent",
|
||||
infoHigh: "--color-info-high",
|
||||
secondHigh: "--color-second-high",
|
||||
@@ -110,13 +107,12 @@ export default function Chart({
|
||||
[colors.border, colors.borderHigh, colors.text],
|
||||
);
|
||||
|
||||
// Make a color list to use inside ChartData for the borderColor and the external tooltip
|
||||
const colorList = React.useMemo(
|
||||
() => [colors.accentHigh, colors.infoHigh, colors.secondHigh],
|
||||
[colors.accentHigh, colors.infoHigh, colors.secondHigh],
|
||||
);
|
||||
|
||||
// Distinct accent/secondary pair so the highlight markers (e.g. build 1 vs build 2) stay tellable apart in both themes
|
||||
// distinct pair so highlight markers (e.g. build 1 vs build 2) stay apart in both themes
|
||||
const markerColors = React.useMemo(
|
||||
() => [colors.accentLow, colors.secondLow],
|
||||
[colors.accentLow, colors.secondLow],
|
||||
@@ -169,7 +165,6 @@ export default function Chart({
|
||||
[options, datasetColors, highlight, markerColors, colors.text],
|
||||
);
|
||||
|
||||
// Draws dashed guide lines from the hovered point to the y-axis (left) and x-axis (bottom)
|
||||
const crosshairPlugin = React.useMemo(
|
||||
() => ({
|
||||
id: "crosshair",
|
||||
|
||||
@@ -9,7 +9,7 @@ interface EmptyStateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Renders the message shown by a page or tab that has no content, with the feature's nav icon above it. */
|
||||
/** Message for a page or tab with no content, with the feature's nav icon above it. */
|
||||
export function EmptyState({ navItem, children }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
|
||||
@@ -37,17 +37,17 @@ export function FormWithConfirm({
|
||||
)[];
|
||||
children?: React.ReactElement<ChildProps>;
|
||||
dialogHeading: string;
|
||||
/** Optional explanatory text shown below the heading in the confirm dialog */
|
||||
/** shown below the heading in the confirm dialog */
|
||||
description?: React.ReactNode;
|
||||
submitButtonText?: string;
|
||||
action?: string;
|
||||
submitButtonTestId?: string;
|
||||
submitButtonVariant?: SendouButtonProps["variant"];
|
||||
fetcher?: FetcherWithComponents<any>;
|
||||
/** Controls the dialog open state. When provided, no child trigger is needed. */
|
||||
/** controlled open state, no child trigger needed */
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
/** Confirming runs this callback instead of submitting a form (client only action) */
|
||||
/** runs instead of submitting a form (client only action) */
|
||||
onConfirm?: () => void;
|
||||
}) {
|
||||
const componentsFetcher = useFetcher();
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* A game's two scanned-timeline charts stacked on one shared time axis and
|
||||
* plot width: per-player status bands above the objective-counter chart.
|
||||
* Hovering scrubs over both — a dotted cursor line spans the charts and a
|
||||
* readout next to the cursor shows the moment's elapsed time, match clock,
|
||||
* scores, penalties, who was in control, who was splatted and who had their
|
||||
* special ready. The chart's own tooltip is turned off in favor of the
|
||||
* readout.
|
||||
* A game's two scanned-timeline charts (player status bands above the objective-counter chart)
|
||||
* on one shared time axis. Hovering scrubs both: a cursor line spans the charts and a readout
|
||||
* shows the moment's state, replacing the chart's own tooltip.
|
||||
*/
|
||||
import clsx from "clsx";
|
||||
import { memo, useRef, useState } from "react";
|
||||
|
||||
@@ -3,24 +3,18 @@ import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
||||
interface LocaleTimeProps {
|
||||
/** The date to render. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
|
||||
/** `Date` or database timestamp */
|
||||
date: Date | number;
|
||||
/** Formatting options forwarded to `Intl.DateTimeFormat`. Combined with the user's locale and hour cycle preferences. */
|
||||
options: Intl.DateTimeFormatOptions;
|
||||
/** Optional extra class names appended to the rendered `<time>` element. */
|
||||
className?: string;
|
||||
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
|
||||
/** defaults to block */
|
||||
inline?: boolean;
|
||||
/** Optional test id forwarded to the rendered `<time>` element. */
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a `<time>` element with the given date formatted according to the user's locale preferences.
|
||||
*
|
||||
* During SSR and before the user's locale preference has loaded the formatted text is hidden
|
||||
* (via `invisible`) while still reserving one line of height to avoid layout shift on hydration.
|
||||
* The `dateTime` attribute is always set to the ISO string for machine readability and a11y.
|
||||
* `<time>` formatted per the user's locale preferences. Before the preference has loaded (SSR)
|
||||
* the text is `invisible` but still reserves one line of height to avoid layout shift.
|
||||
*/
|
||||
export function LocaleTime({
|
||||
date,
|
||||
@@ -36,9 +30,8 @@ export function LocaleTime({
|
||||
|
||||
return (
|
||||
<time
|
||||
// Hydration warnings are suppressed because callers may pass a live "now" value (e.g. a clock)
|
||||
// whose server and client render instants differ slightly, which would otherwise mismatch the
|
||||
// `dateTime` attribute. For fixed dates the ISO string is deterministic, so nothing real is masked.
|
||||
// a live "now" value (e.g. a clock) differs slightly between server and client render,
|
||||
// which would mismatch `dateTime`; fixed dates are deterministic so nothing real is masked
|
||||
suppressHydrationWarning
|
||||
data-testid={testId}
|
||||
dateTime={dateObject.toISOString()}
|
||||
|
||||
@@ -3,27 +3,21 @@ import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
||||
interface LocaleTimeRangeProps {
|
||||
/** Start of the range. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
|
||||
/** `Date` or database timestamp */
|
||||
from: Date | number;
|
||||
/** End of the range. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
|
||||
/** `Date` or database timestamp */
|
||||
to: Date | number;
|
||||
/** Formatting options forwarded to `Intl.DateTimeFormat`. Combined with the user's locale and hour cycle preferences. */
|
||||
options: Intl.DateTimeFormatOptions;
|
||||
/** Optional extra class names appended to the rendered element. */
|
||||
className?: string;
|
||||
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
|
||||
/** defaults to block */
|
||||
inline?: boolean;
|
||||
/** Optional test id forwarded to the rendered element. */
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the given date range formatted according to the user's locale preferences,
|
||||
* using `Intl.DateTimeFormat.prototype.formatRange` for locale-aware separators and
|
||||
* collapsing of shared parts (e.g. the year when both bounds share it).
|
||||
*
|
||||
* During SSR and before the user's locale preference has loaded the formatted text is hidden
|
||||
* (via `invisible`) while still reserving one line of height to avoid layout shift on hydration.
|
||||
* Date range via `Intl.DateTimeFormat.formatRange` (locale-aware separators, shared parts
|
||||
* collapsed). Before the locale preference has loaded (SSR) the text is `invisible` but still
|
||||
* reserves one line of height to avoid layout shift.
|
||||
*/
|
||||
export function LocaleTimeRange({
|
||||
from,
|
||||
|
||||
@@ -25,13 +25,9 @@ const SIZE_CLASS = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Wraps an avatar (or any node) and overlays a sentiment badge on the bottom-left corner when
|
||||
* `sentiment` is set: POSITIVE → green check, NEGATIVE → red cross, NEUTRAL → grey dash. Renders the
|
||||
* children without a badge when `sentiment` is `null`/`undefined`. `size` scales the badge to match
|
||||
* the wrapped avatar (`xs` for tiny avatars, `sm` for small avatars, `md` for large ones).
|
||||
*
|
||||
* `onClick` makes the whole wrapper (avatar and badge) clickable. It is kept out of the tab order, so
|
||||
* only use it as a shortcut to an action that is also available elsewhere.
|
||||
* Overlays a sentiment badge (check / cross / dash) on the bottom-left of the wrapped avatar;
|
||||
* `size` matches the avatar. `onClick` is kept out of the tab order, so only use it as a shortcut
|
||||
* to an action also available elsewhere.
|
||||
*/
|
||||
export function NoteAvatar({
|
||||
sentiment,
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
/**
|
||||
* Line chart of a game's objective-counter reads: one line per team
|
||||
* (remaining count over match time, so lines fall toward 0). Control is a
|
||||
* state rather than a count, so it gets its own lane in a gutter below the
|
||||
* zero gridline instead of sharing the count axis — a strip in the
|
||||
* controlling team's color, absent while neither team controls. The
|
||||
* zero gridline is drawn in the stronger border color to read as the
|
||||
* divider between the counts above and the lane below. Penalty is a
|
||||
* translucent band filled between score and score + penalty — its thickness
|
||||
* is the extra count the team must burn through before its score moves
|
||||
* again, so it grows when a penalty lands and shrinks as it counts down.
|
||||
* Control state and exact values stay in the shared hover tooltip.
|
||||
*
|
||||
* Series colors are the chart tokens from vars.css — the theme's text-tier
|
||||
* colors are too pastel to tell apart as marks; these are the same two hues
|
||||
* re-stepped per theme and validated for CVD separation and surface
|
||||
* contrast.
|
||||
* Line chart of a game's objective-counter reads, one falling line per team. Control is a state,
|
||||
* not a count, so it gets its own lane below the zero gridline (strip in the controlling team's
|
||||
* color); penalty is a translucent band between score and score + penalty, so its thickness is
|
||||
* the extra count to burn through. Series colors are the chart tokens from vars.css: the text-tier
|
||||
* colors are too pastel to tell apart as marks.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -261,10 +250,7 @@ export function ObjectiveTimeline({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tick every 25 up to the top of the data and none below zero, so the
|
||||
* control gutter stays free of axis furniture.
|
||||
*/
|
||||
/** One tick every 25 up to the top of the data, none below zero so the control gutter stays clean. */
|
||||
function countAxisTicks(max: number) {
|
||||
const ticks = [];
|
||||
for (let value = 0; value <= max; value += COUNT_TICK_STEP) {
|
||||
|
||||
@@ -87,9 +87,7 @@ describe("getPageNumbers", () => {
|
||||
});
|
||||
|
||||
test("shows a bridging number instead of an ellipsis that hides a single page", () => {
|
||||
// An ellipsis takes the same space as one page number, so replacing a
|
||||
// lone hidden page with "..." is never an improvement (same intent as the
|
||||
// edge "lonely jump" fix, but for windows one step inward).
|
||||
// an ellipsis takes the same space as one page number, so hiding a lone page is never an improvement
|
||||
// desktop window around page 5 of 10 leaves only page 2 hidden on the left
|
||||
expect(desktopView(5, 10)).toEqual([1, 2, 3, 4, 5, 6, 7, "...", 10]);
|
||||
// ...and only page 9 hidden on the right for page 6 of 10
|
||||
|
||||
@@ -249,15 +249,10 @@ export function getPageNumbers(
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive range of inner page numbers (excluding the always-shown first and
|
||||
* last page) to render around the current page. The window is nudged inward by
|
||||
* one when the current page is the very first or last page, so the edge view
|
||||
* shows a bridging number instead of a lonely jump like "1 2 … 8".
|
||||
*
|
||||
* When exactly one page would be left between the window and the always-shown
|
||||
* first or last page, the window is widened to include it: an ellipsis takes
|
||||
* the same space as a single page number, so "1 … 3" is never better than
|
||||
* "1 2 3".
|
||||
* Inclusive range of inner page numbers (first and last are always shown) around the current
|
||||
* page. Nudged inward by one at the very first/last page so the edge shows a bridging number
|
||||
* instead of "1 2 … 8", and widened when exactly one page would be hidden: an ellipsis takes the
|
||||
* same space, so "1 … 3" is never better than "1 2 3".
|
||||
*/
|
||||
function innerPageWindow(
|
||||
currentPage: number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import styles from "./Placeholder.module.css";
|
||||
|
||||
/** Renders a blank placeholder component that can be used while content is loading. Better than returning null because it keeps the footer down where it belongs. */
|
||||
/** Blank placeholder while content loads; unlike null it keeps the footer down. */
|
||||
export function Placeholder() {
|
||||
return <div className={styles.placeholder} />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/**
|
||||
* Per-player status bands over a game's scanned icon-strip reads: one row
|
||||
* per player (weapon icon as the label), a band while the player was
|
||||
* splatted and another while they held their special, both teams stacked.
|
||||
* Rendered above the ObjectiveTimeline chart on the same `t` seconds axis —
|
||||
* pass `domain` so both span the same range. Reads re-confirm an unchanged
|
||||
* state every few seconds; a longer sample gap means the HUD was not
|
||||
* observed, so bands never bridge across one (the state there is unknown,
|
||||
* not continued).
|
||||
* Per-player splatted / special-held bands over a game's scanned icon-strip reads, rendered above
|
||||
* the ObjectiveTimeline on the same `t` axis (pass `domain` to share the range). Reads re-confirm
|
||||
* an unchanged state every few seconds; a longer gap means the HUD was not observed, so bands never
|
||||
* bridge across one.
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
@@ -150,10 +146,8 @@ interface StatusSpan {
|
||||
}
|
||||
|
||||
/**
|
||||
* Contiguous stretches where the flag held true: a span opens at its first
|
||||
* true read and closes at the read that shows false — or one second past
|
||||
* its last confirmation when the next read is too far away (or the series
|
||||
* ends) to know what happened in between.
|
||||
* Contiguous stretches where the flag held true: opens at the first true read, closes at the read
|
||||
* showing false, or one second past the last confirmation when the next read is too far away.
|
||||
*/
|
||||
export function statusSpans(
|
||||
sorted: readonly PlayerStatusTimelineSample[],
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/**
|
||||
* Rows of ranked entries (rank, avatar, weapon, name, power) as rendered by the
|
||||
* leaderboards and the X Rank top search pages.
|
||||
*/
|
||||
/** Rows of ranked entries (rank, avatar, weapon, name, power) for the leaderboards and X Rank top search pages. */
|
||||
|
||||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
|
||||
/**
|
||||
* An SP change, rendered as a colored arrow followed by the size of the change.
|
||||
* A change of exactly zero gets no arrow. Meant to be placed in a flex or grid
|
||||
* container that spaces the two apart.
|
||||
*/
|
||||
/** SP change as a colored arrow (none for zero) and the size; place in a flex/grid container spacing the two. */
|
||||
export function SpDelta({ diff }: { diff: number }) {
|
||||
const rounded = roundToNDecimalPlaces(diff);
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@ import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { stageBannerImageUrl } from "~/utils/urls";
|
||||
import styles from "./StageBannerBox.module.css";
|
||||
|
||||
/**
|
||||
* Box with a stage banner image fading in from the right. The fade color
|
||||
* defaults to `--color-bg-high`; override per use with the
|
||||
* `--stage-banner-fade` CSS variable.
|
||||
*/
|
||||
/** Box with a stage banner fading in from the right; fade color via `--stage-banner-fade` (default `--color-bg-high`). */
|
||||
export function StageBannerBox({
|
||||
stageId,
|
||||
className,
|
||||
|
||||
@@ -4,14 +4,13 @@ import type { AnySchema } from "~/utils/schema";
|
||||
import { SendouButton, type SendouButtonProps } from "./elements/Button";
|
||||
|
||||
type SubmitButtonProps<TSchema extends AnySchema> = SendouButtonProps & {
|
||||
/** If the page has multiple forms you can pass in fetcher.state to differentiate when this SubmitButton should be in submitting state */
|
||||
/** fetcher.state, to tell apart submitting state between multiple forms */
|
||||
state?: FetcherWithComponents<any>["state"];
|
||||
testId?: string;
|
||||
} & (
|
||||
| {
|
||||
/** Action schema of the route the form submits to. Only used for typing `_action`. */
|
||||
/** Route's action schema, only used for typing `_action`. */
|
||||
schema: TSchema;
|
||||
/** `_action` to submit, narrowed to the literals of the schema. */
|
||||
_action: ActionsOf<TSchema>;
|
||||
}
|
||||
| { schema?: never; _action?: never }
|
||||
|
||||
@@ -18,7 +18,7 @@ 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. */
|
||||
/** Avatar + username link; result players without an account render just their name. */
|
||||
export function UserLink({
|
||||
user,
|
||||
size = "xxs",
|
||||
|
||||
@@ -45,7 +45,7 @@ interface WeaponSelectProps<
|
||||
disabledWeaponIds?: Array<MainWeaponId>;
|
||||
testId?: string;
|
||||
isRequired?: boolean;
|
||||
/** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */
|
||||
/** Shown while the search input is empty, e.g. previous selections */
|
||||
quickSelectWeaponsIds?: Array<MainWeaponId>;
|
||||
isDisabled?: boolean;
|
||||
placeholder?: string;
|
||||
@@ -253,9 +253,8 @@ function useWeaponItems({
|
||||
const [filterValue, setFilterValue] = React.useState("");
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
// While closed only the selected item is needed (the trigger's value
|
||||
// display); react-aria renders every item passed to it into a hidden
|
||||
// collection even when the popover is closed.
|
||||
// react-aria renders every item into a hidden collection even while closed,
|
||||
// when only the selected item (the trigger's value) is needed
|
||||
if (!isOpen) {
|
||||
return {
|
||||
items: collapseToSelectedItem(items, selectedKey),
|
||||
@@ -298,7 +297,6 @@ function useWeaponItems({
|
||||
};
|
||||
|
||||
return {
|
||||
// not too sure why we need to type cast here.. was working fine before refactoring
|
||||
items: [quickSelectCategory] as typeof items,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
@@ -401,8 +399,7 @@ function keyify(value?: MainWeaponId | AnyWeapon | null) {
|
||||
function collapseToSelectedItem<
|
||||
Category extends { items: Array<{ weapon: { anyWeaponId: string } }> },
|
||||
>(categories: Category[], selectedKey: string | null | undefined): Category[] {
|
||||
// react-stately refuses to open a select whose collection is empty, so even
|
||||
// with nothing selected the closed collection keeps one item around.
|
||||
// react-stately refuses to open a select with an empty collection, so one item is always kept
|
||||
const fallbackItems = () => {
|
||||
const firstCategory = categories[0];
|
||||
if (!firstCategory) return [];
|
||||
|
||||
@@ -190,10 +190,7 @@ function iconClassName(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the button look on a plain element, for when the interactive element
|
||||
* is elsewhere, e.g. the box inside a tab.
|
||||
*/
|
||||
/** Button look on a plain element, for when the interactive element is elsewhere (e.g. the box inside a tab). */
|
||||
export function ButtonLook({
|
||||
className,
|
||||
children,
|
||||
|
||||
@@ -17,7 +17,7 @@ import styles from "./Calendar.module.css";
|
||||
export interface SendouCalendarProps<T extends DateValue>
|
||||
extends CalendarProps<T> {
|
||||
className?: string;
|
||||
/** Highlights the whole week row rather than a single day, for pickers where choosing a day means choosing the week it belongs to. */
|
||||
/** Selecting a day selects (and highlights) its whole week. */
|
||||
weekSelection?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,41 +19,19 @@ interface SendouDialogProps extends ModalOverlayProps {
|
||||
heading?: string;
|
||||
showHeading?: boolean;
|
||||
onClose?: () => void;
|
||||
/** When closing the modal which URL to navigate to */
|
||||
/** URL to navigate to on close */
|
||||
onCloseTo?: string;
|
||||
overlayClassName?: string;
|
||||
"aria-label"?: string;
|
||||
/** If true, the modal takes over the full screen with the content below hidden */
|
||||
/** takes over the full screen, hiding the content below */
|
||||
isFullScreen?: boolean;
|
||||
/** If true, shows the close button even if onClose is not provided */
|
||||
/** show the close button even without onClose */
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows you to create a dialog with a customizable trigger and content.
|
||||
* It supports both controlled and uncontrolled modes for managing the dialog's open state.
|
||||
*
|
||||
* @example
|
||||
* // Example usage with implicit isOpen
|
||||
* return (
|
||||
* <SendouDialog
|
||||
* heading="Dialog Title"
|
||||
* onCloseTo={previousPageUrl()}
|
||||
* >
|
||||
* This is the dialog content.
|
||||
* </SendouDialog>
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Example usage with a SendouButton as the trigger
|
||||
* return (
|
||||
* <SendouDialog
|
||||
* heading="Dialog Title"
|
||||
* trigger={<SendouButton>Open Dialog</SendouButton>}
|
||||
* >
|
||||
* This is the dialog content.
|
||||
* </SendouDialog>
|
||||
* );
|
||||
* Dialog that is open by default without a `trigger` (or controlled via `isOpen`), or opened by
|
||||
* the given `trigger` element.
|
||||
*/
|
||||
export function SendouDialog({
|
||||
trigger,
|
||||
|
||||
@@ -7,19 +7,7 @@ import {
|
||||
} from "react-aria-components";
|
||||
import styles from "./Popover.module.css";
|
||||
|
||||
/**
|
||||
* A reusable popover component that wraps around a trigger element (SendouButton or Button from React Aria Components library).
|
||||
* Supports controlled and uncontrolled open states.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SendouPopover
|
||||
* trigger={<SendouButton>Click me</SendouButton>}
|
||||
* >
|
||||
* Popover content goes here!
|
||||
* </SendouPopover>
|
||||
* ```
|
||||
*/
|
||||
/** Popover opened by `trigger` (a SendouButton or React Aria Button); controlled or uncontrolled. */
|
||||
export function SendouPopover({
|
||||
children,
|
||||
trigger,
|
||||
@@ -48,11 +36,7 @@ export function SendouPopover({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover anchored to a trigger rendered outside of it, with its open state
|
||||
* controlled by the caller. Prefer `SendouPopover` when the trigger can be
|
||||
* passed in.
|
||||
*/
|
||||
/** Controlled popover anchored to a trigger rendered outside of it. Prefer `SendouPopover` when the trigger can be passed in. */
|
||||
export function SendouAnchoredPopover({
|
||||
children,
|
||||
isOpen,
|
||||
|
||||
@@ -56,11 +56,7 @@ interface SearchSelectProps<
|
||||
renderItem: (item: TItem) => React.ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational autocomplete select shared by the entity search components
|
||||
* (e.g. `UserSearch`, `TeamSearch`, `TournamentSearch`). Wire up data fetching
|
||||
* with `useEntitySearch` and pass its result as `search`.
|
||||
*/
|
||||
/** Presentational autocomplete select for the entity searches (`UserSearch` etc.); `search` comes from `useEntitySearch`. */
|
||||
export function SearchSelect<
|
||||
TItem extends { id: number; name: string },
|
||||
T extends object,
|
||||
@@ -131,8 +127,7 @@ function PlaceholderItem({
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
// for some reason the `renderEmptyState` on ListBox is not working
|
||||
// so doing this as a workaround
|
||||
// workaround for `renderEmptyState` on ListBox not working
|
||||
return (
|
||||
<ListBoxItem
|
||||
textValue="PLACEHOLDER"
|
||||
@@ -146,11 +141,7 @@ function PlaceholderItem({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One result inside a `SearchSelect`'s list: an optional leading avatar or
|
||||
* logo, then the texts. `SearchSelectItemAdditionalText` renders the muted
|
||||
* second line, which is hidden while the item is shown in the trigger.
|
||||
*/
|
||||
/** One `SearchSelect` result; `SearchSelectItemAdditionalText` is the muted second line, hidden in the trigger. */
|
||||
export function SearchSelectItem({
|
||||
id,
|
||||
textValue,
|
||||
|
||||
@@ -45,9 +45,9 @@ export interface SendouSelectProps<T extends object>
|
||||
placeholder?: string;
|
||||
};
|
||||
popoverClassName?: string;
|
||||
/** Value of the search input, used for controlled components */
|
||||
/** controlled search input value */
|
||||
searchInputValue?: string;
|
||||
/** Callback for when the search input value changes. When defined `items` has to be filtered on the caller side (automatic filtering in component disabled). */
|
||||
/** When defined, the caller filters `items` (automatic filtering disabled). */
|
||||
onSearchInputChange?: (value: string) => void;
|
||||
clearable?: boolean;
|
||||
filter?: AutocompleteProps<object>["filter"];
|
||||
@@ -55,20 +55,7 @@ export interface SendouSelectProps<T extends object>
|
||||
estimatedRowHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A customizable select component with optional search functionality. Virtualizes the list of items for performance.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SendouSelect items={items} search={{ placeholder: "Search for items..." }}>
|
||||
* {({ key, ...item }) => (
|
||||
* <SendouSelectItem key={key} {...item}>
|
||||
* {item.name}
|
||||
* </SendouSelectItem>
|
||||
* )}
|
||||
* </SendouSelect>
|
||||
* ```
|
||||
*/
|
||||
/** Select with optional search; virtualizes the item list. */
|
||||
export function SendouSelect<T extends object>({
|
||||
label,
|
||||
description,
|
||||
@@ -121,9 +108,8 @@ export function SendouSelect<T extends object>({
|
||||
</Virtualizer>
|
||||
);
|
||||
|
||||
// The Autocomplete wrapper filters the collection, but its filtering drops
|
||||
// items with a falsy key (e.g. `0`). When there is nothing to filter we skip
|
||||
// it entirely so such items always render.
|
||||
// the Autocomplete wrapper's filtering drops items with a falsy key (e.g. `0`),
|
||||
// so it is skipped entirely when there is nothing to filter
|
||||
const filterable = !!search || isControlled || !!filter;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* The select look shared by `SendouSelect` and `SearchSelect`: the trigger
|
||||
* button, the popover with its search field, and the list box items' focus and
|
||||
* selection states. Both selects render their own contents inside these.
|
||||
*/
|
||||
/** Select look (trigger, popover with search field, list box item states) shared by `SendouSelect` and `SearchSelect`. */
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ChevronsUpDown, Search, X } from "lucide-react";
|
||||
|
||||
@@ -16,42 +16,15 @@ import { ButtonLook } from "./Button";
|
||||
import styles from "./Tabs.module.css";
|
||||
|
||||
interface SendouTabsProps extends TabsProps {
|
||||
/** Should there be padding above the panels. Defaults to true, pass in false if the panel content is managing its own padding. */
|
||||
/** Padding above the panels, default true. */
|
||||
padded?: boolean;
|
||||
/** Hide tabs if only one tab shown? Defaults to true. */
|
||||
/** Hide tabs if only one tab shown, default true. */
|
||||
disappearing?: boolean;
|
||||
/** When orientation is "vertical", switch to horizontal once the main content width drops below this many pixels. */
|
||||
/** Vertical orientation switches to horizontal below this main content width (px). */
|
||||
horizontalBelow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a set of accessible tabs using the provided props.
|
||||
*
|
||||
* This component is a wrapper around the `Tabs` component, forwarding all props.
|
||||
*
|
||||
* @param props - The properties to pass to the underlying `Tabs` component.
|
||||
* @returns The rendered tab interface.
|
||||
*
|
||||
* @url https://react-spectrum.adobe.com/react-aria/Tabs.html
|
||||
*
|
||||
* @example
|
||||
* <SendouTabs>
|
||||
* <SendouTabList>
|
||||
* <Tab id="shooter">Shooter</Tab>
|
||||
* <Tab id="roller">Roller</Tab>
|
||||
* <Tab id="charger">Charger</Tab>
|
||||
* </SendouTabList>
|
||||
* <SendouTabPanel id="shooter">
|
||||
* Splattershot, Aerospray, etc.
|
||||
* </SendouTabPanel>
|
||||
* <SendouTabPanel id="roller">
|
||||
* Splat Roller, Dynamo Roller, etc.
|
||||
* </SendouTabPanel>
|
||||
* <SendouTabPanel id="charger">
|
||||
* Splat Charger, E-liter, etc.
|
||||
* </SendouTabPanel>
|
||||
* </SendouTabs>
|
||||
*/
|
||||
/** Wrapper around react-aria `Tabs`, see https://react-spectrum.adobe.com/react-aria/Tabs.html */
|
||||
export function SendouTabs({
|
||||
padded = true,
|
||||
disappearing = true,
|
||||
@@ -89,7 +62,7 @@ export function SendouTabs({
|
||||
interface SendouTabProps extends TabProps {
|
||||
icon?: React.ReactNode;
|
||||
number?: number;
|
||||
/** Render a warning-colored alert icon to draw attention to this tab. */
|
||||
/** warning-colored alert icon on the tab */
|
||||
alert?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
@@ -117,7 +90,7 @@ export function SendouTab({
|
||||
|
||||
interface SendouTabListProps<T extends object> extends TabListProps<T> {
|
||||
sticky?: boolean;
|
||||
/** Should tabs take 100% width with equal distribution? */
|
||||
/** tabs share 100% width equally */
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
@@ -130,8 +103,6 @@ export function SendouTabList<T extends object>({
|
||||
<div className={clsx(styles.tabListContainer, "scrollbar")}>
|
||||
<TabList
|
||||
className={clsx(styles.tabList, {
|
||||
// invisible: cantSwitchTabs && !disappearing,
|
||||
// hidden: cantSwitchTabs && disappearing,
|
||||
[styles.sticky]: sticky,
|
||||
[styles.fullWidth]: fullWidth,
|
||||
})}
|
||||
|
||||
@@ -20,7 +20,7 @@ interface TeamSearchProps<T extends object>
|
||||
label?: string;
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
/** Team to preselect and display on mount (e.g. when editing a linked team). */
|
||||
/** preselected on mount (e.g. when editing a linked team) */
|
||||
initialTeam?: { id: number; name: string; avatarUrl?: string | null };
|
||||
onChange?: (team: TeamSearchResult | null) => void;
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
|
||||
@@ -23,11 +23,7 @@ interface TournamentSearchProps<T extends object>
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
initialTournamentId?: number;
|
||||
/**
|
||||
* Restrict results to tournaments that have already started (finished/past)
|
||||
* instead of the default recent + upcoming window. Useful e.g. for importing
|
||||
* data from a previous tournament.
|
||||
*/
|
||||
/** Only tournaments that have already started, instead of the default recent + upcoming window. */
|
||||
pastOnly?: boolean;
|
||||
onChange?: (tournament: TournamentSearchItem | null) => void;
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
|
||||
@@ -76,11 +76,7 @@ function parseUserResults(
|
||||
.filter((user) => user.id !== initialUser?.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the full user object for a preselected id so it can be displayed.
|
||||
* Loads at most once per field: later id changes come from the user picking a
|
||||
* result, which already carries the full user object.
|
||||
*/
|
||||
/** Loads the preselected id's user once; later changes come from picked results which carry the full user. */
|
||||
function useInitialUser(initialUserId?: number) {
|
||||
const fetcher = useFetcher<SearchLoaderData>();
|
||||
const { load } = fetcher;
|
||||
|
||||
@@ -12,14 +12,11 @@ export type EntitySearchItem<TItem> = TItem | EntitySearchPlaceholder;
|
||||
interface UseEntitySearchArgs<TItem extends { id: number }> {
|
||||
/** Builds the loader URL queried (debounced) as the user types. */
|
||||
buildUrl: (query: string) => string;
|
||||
/**
|
||||
* Turns raw loader data into result items. Return `null` when the data does
|
||||
* not (yet) correspond to the current query so a placeholder is shown.
|
||||
*/
|
||||
/** Return `null` when the data does not (yet) correspond to the query, showing a placeholder. */
|
||||
parseResults: (data: unknown, query: string) => TItem[] | null;
|
||||
/** Already resolved item to pin to the top of the list (e.g. when editing). */
|
||||
/** pinned to the top of the list (e.g. when editing) */
|
||||
initialItem?: TItem;
|
||||
/** Id to preselect on mount even before its item is resolved. */
|
||||
/** preselected on mount even before its item is resolved */
|
||||
initialSelectedId?: number;
|
||||
onChange?: (item: TItem | null) => void;
|
||||
}
|
||||
@@ -32,12 +29,7 @@ export interface EntitySearch<TItem extends { id: number }> {
|
||||
onSelectionChange: (key: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared state + data fetching for the autocomplete search selects
|
||||
* (e.g. `UserSearch`, `TeamSearch`, `TournamentSearch`). Pair with the
|
||||
* presentational `SearchSelect` component, passing the returned value as its
|
||||
* `search` prop.
|
||||
*/
|
||||
/** State + data fetching for the entity search selects (`UserSearch` etc.); pass the result as `SearchSelect`'s `search` prop. */
|
||||
export function useEntitySearch<TItem extends { id: number }>({
|
||||
buildUrl,
|
||||
parseResults,
|
||||
|
||||
@@ -245,14 +245,14 @@
|
||||
overflow: hidden;
|
||||
padding-block-start: var(--s-2);
|
||||
|
||||
/* Chat section.container - fill the space */
|
||||
/* Chat section.container */
|
||||
& > section {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* inputContainer wraps both messages + form - make it fill and flex */
|
||||
/* inputContainer wrapping messages + form */
|
||||
& > section > div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -261,7 +261,7 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* messages: no top padding, fill available space */
|
||||
/* messages */
|
||||
& [role="listbox"] {
|
||||
padding-top: 0;
|
||||
flex: 1;
|
||||
@@ -272,7 +272,6 @@
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
/* form: stick to bottom */
|
||||
& form {
|
||||
padding: var(--s-1-5);
|
||||
margin-top: 0;
|
||||
|
||||
@@ -165,8 +165,7 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
||||
return room ? [{ ...entry, room }] : [];
|
||||
});
|
||||
|
||||
// Rooms the active route opens together collapse into a single combined list
|
||||
// entry that opens the stacked split view.
|
||||
// rooms the active route opens together collapse into one entry opening the stacked split view
|
||||
const autoOpenRooms = routeRooms.filter((entry) => entry.autoOpen);
|
||||
const combinedRooms = autoOpenRooms.length > 1 ? autoOpenRooms : [];
|
||||
const combinedRoomIds = new Set(combinedRooms.map((entry) => entry.room.id));
|
||||
@@ -449,9 +448,8 @@ function CombinedChatView({
|
||||
</>
|
||||
);
|
||||
|
||||
// Primary (match) sits on top, flush below the main header which already names
|
||||
// it, so its sub-header is hidden. Desktop splits evenly; mobile gives the
|
||||
// match chat the larger 3/5 share (group chat 2/5).
|
||||
// primary (match) sits on top with its sub-header hidden, the main header already names it;
|
||||
// desktop splits evenly, mobile gives the match chat 3/5
|
||||
const panels = [
|
||||
{ room: primary, grow: isMobile ? 3 : 1, showHeader: false },
|
||||
...rooms.slice(1).map((room) => ({
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* The result list chrome shared by the global search and its weapon sub-view:
|
||||
* the list box, its items and the empty state shown while there is nothing to
|
||||
* list.
|
||||
*/
|
||||
/** Result list chrome (list box, items, empty state) shared by the global search and its weapon sub-view. */
|
||||
|
||||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
|
||||
@@ -60,11 +60,7 @@ export interface SelectedWeapon {
|
||||
paramsSlug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link SelectedWeapon} for a main weapon id: its localized name plus the English-derived
|
||||
* url slugs (the build pages slug from the weapon's canonical id, the params page slug from its base
|
||||
* id). The caller's `t` must have the `weapons` namespace available.
|
||||
*/
|
||||
/** {@link SelectedWeapon} for a main weapon id: localized name plus English-derived url slugs. `t` needs the `weapons` namespace. */
|
||||
export function weaponToSelectedWeapon<Ns extends Namespace>(
|
||||
id: MainWeaponId,
|
||||
t: TFunction<Ns>,
|
||||
|
||||
@@ -60,19 +60,14 @@ import { TopRightButtons } from "./TopRightButtons";
|
||||
|
||||
const MAX_DESKTOP_FRIENDS = 4;
|
||||
|
||||
// lazy loaded so the rarely needed auth error dialog stays out of the eager
|
||||
// bundle loaded on every page
|
||||
// lazy loaded to stay out of the eager bundle
|
||||
const AuthErrorDialog = React.lazy(() =>
|
||||
import("./AuthErrorDialog").then((module) => ({
|
||||
default: module.AuthErrorDialog,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Id of the loading-bar track rendered inside the header. NProgress mounts its
|
||||
* bar into it; the track sits just below the header border, spans only the area
|
||||
* between the sidebars, and clips the bar so it never extends over a sidebar.
|
||||
* Living inside the header makes it follow the header on scroll and in
|
||||
* standalone (PWA) mode where the header grows by the safe-area inset. */
|
||||
/** Loading-bar track inside the header that NProgress mounts into; styled in common.css. */
|
||||
export const NPROGRESS_ANCHOR_ID = "nprogress-anchor";
|
||||
|
||||
function useRelativeDayFormat() {
|
||||
@@ -147,10 +142,7 @@ function useSideNavCollapsed(initialCollapsed: boolean) {
|
||||
return [collapsed, setCollapsedAndPersist] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open state of a modal that only the tablet layout has, remembering the pathname it was
|
||||
* opened on so that leaving that layout or navigating elsewhere closes it on its own.
|
||||
*/
|
||||
/** Open state of a tablet-layout-only modal; leaving that layout or navigating closes it. */
|
||||
function useTabletModal(isTabletLayout: boolean) {
|
||||
const location = useLocation();
|
||||
const [openedOnPathname, setOpenedOnPathname] = React.useState<string | null>(
|
||||
|
||||
@@ -215,10 +215,7 @@ function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger for the small popovers hung off a banner's info row, e.g. who voted
|
||||
* for the map or which team picked it.
|
||||
*/
|
||||
/** Trigger for the small popovers on a banner's info row, e.g. who voted for the map. */
|
||||
export function MatchBannerInfoBadge({
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -13,7 +13,7 @@ const CLASS_NAME = "text-lighter font-semi-bold";
|
||||
|
||||
interface MatchBannerStartedAtProps {
|
||||
time: Date;
|
||||
/** When given, the time the match ended, shown as a range together with the start time */
|
||||
/** shown as a range together with the start time */
|
||||
endTime?: Date | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,10 +37,10 @@ interface RosterTabTeam {
|
||||
};
|
||||
defaultName?: string;
|
||||
members: Array<RosterTabMember>;
|
||||
/** Sub user ids i.e. those who are not the current active roster */
|
||||
/** users not in the current active roster */
|
||||
subbedOut?: Array<number>;
|
||||
tier?: { name: TierName; isPlus: boolean };
|
||||
/** Tournament seed of the team (tournament only). */
|
||||
/** tournament only */
|
||||
seed?: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ type MatchTabsKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];
|
||||
interface MatchTabsProps {
|
||||
children: React.ReactNode;
|
||||
tabs: Array<MatchTabsKey>;
|
||||
/** Tabs that should show a warning-colored alert icon to draw attention. */
|
||||
/** tabs showing a warning-colored alert icon */
|
||||
alertTabs?: Array<MatchTabsKey>;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,17 +82,17 @@ export interface TimelineMap {
|
||||
};
|
||||
/** Whether the game ended in a knockout. Undefined if not collected. */
|
||||
ko?: boolean;
|
||||
/** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */
|
||||
/** Side that picked this map (counterpick / postGame map PICK), shown as a click indicator. */
|
||||
pickedBy?: MatchSide;
|
||||
/** Ingested end-of-game scoreboard rendered as an expandable stats section below the map row. */
|
||||
/** Ingested end-of-game scoreboard, an expandable stats section below the map row. */
|
||||
scoreboard?: {
|
||||
/** [alpha, bravo] on the ingested 0-100 scale (100 = knockout) */
|
||||
scores: [number | null, number | null];
|
||||
alpha: TimelineScoreboardPlayer[];
|
||||
bravo: TimelineScoreboardPlayer[];
|
||||
/** Objective-counter reads ([alpha, bravo] values) charted above the stats tables. */
|
||||
/** [alpha, bravo] objective-counter reads charted above the stats tables */
|
||||
objective?: ObjectiveTimelineEvent[];
|
||||
/** Per-player splat/special bands ([alpha, bravo]) charted above the objective chart. */
|
||||
/** [alpha, bravo] per-player splat/special bands charted above the objective chart */
|
||||
playerStatus?: PlayerStatusTimelineSample[];
|
||||
};
|
||||
}
|
||||
@@ -126,15 +126,13 @@ export interface MatchTimelineProps {
|
||||
score?: { alpha: number; bravo: number };
|
||||
maps: TimelineMap[];
|
||||
spChanges?: TimelineSpChanges;
|
||||
/** When true, render only the team + score header (no per-map rows or SP section). */
|
||||
/** only the team + score header, no per-map rows or SP section */
|
||||
compact?: boolean;
|
||||
/** When true, the match is still in progress; renders a small LIVE label under the score. */
|
||||
/** renders a LIVE label under the score */
|
||||
isOngoing?: boolean;
|
||||
/**
|
||||
* Pick/ban events keyed by the slot they precede. Length = `maps.length + 1`.
|
||||
* Bucket `i` renders above map row `i`; the trailing bucket renders after the
|
||||
* last map row (covers events made after the latest result, or the
|
||||
* pick/ban-only state with no maps reported yet).
|
||||
* Pick/ban events keyed by the slot they precede, length `maps.length + 1`. Bucket `i` renders
|
||||
* above map row `i`; the trailing bucket after the last row (events after the latest result).
|
||||
*/
|
||||
pickBanRowsBySlot?: TimelinePickBanEvent[][];
|
||||
}
|
||||
@@ -375,11 +373,7 @@ interface SideScore {
|
||||
fromObjective: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A knockout's loser is reported with no score of its own, so the count it
|
||||
* took is only known from the objective counter — prefer that read over a
|
||||
* scoreless 0, and mark it as the video-sourced value it is.
|
||||
*/
|
||||
/** A knockout's loser has no reported score, so prefer the objective counter read over a scoreless 0. */
|
||||
function resolveSideScore(
|
||||
scoreboardScore?: number | null,
|
||||
objectiveScore?: number | null,
|
||||
|
||||
@@ -10,26 +10,14 @@ interface SecondaryActionProps {
|
||||
collapsedLabel: string;
|
||||
collapsedIcon?: React.JSX.Element;
|
||||
expandedAriaLabel?: string;
|
||||
/**
|
||||
* Always-open variant used when this is the only content in the tab (no
|
||||
* primary action to sit underneath). Hides the collapse toggle and drops the
|
||||
* striped footer styling.
|
||||
*/
|
||||
/** Only content in the tab: always open, no collapse toggle, no striped footer styling. */
|
||||
standalone?: boolean;
|
||||
/**
|
||||
* Forces the expanded state and hides the collapse toggle while keeping the
|
||||
* footer styling. Used when the expanded content is small enough that
|
||||
* collapsing brings no benefit.
|
||||
*/
|
||||
/** Always open without the collapse toggle, keeping the footer styling. */
|
||||
alwaysOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic panel hosting follow-up match actions (e.g. weapon reporting, scrim
|
||||
* map list management). Defaults to a striped footer attached beneath the
|
||||
* primary action card; pass `standalone` when it is the only tab content.
|
||||
*/
|
||||
/** Panel for follow-up match actions (weapon reporting etc.), a striped footer beneath the primary action card. */
|
||||
export function SecondaryAction({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
|
||||
@@ -24,8 +24,7 @@ export interface WeaponReporterMap {
|
||||
}
|
||||
|
||||
export interface WeaponReporterProps {
|
||||
/** Only the maps the viewer took part in, so someone who was subbed out is
|
||||
* never asked for a weapon of a map they did not play. */
|
||||
/** Only the maps the viewer took part in, so a subbed out player is never asked for one they did not play. */
|
||||
maps: WeaponReporterMap[];
|
||||
pastReported: MainWeaponId[];
|
||||
nextMapIndex: number;
|
||||
|
||||
@@ -11,10 +11,7 @@ import type { WeaponPoolWeapon } from "./WeaponPool";
|
||||
/** Ingested scoreboard rows come winning team first, 4 players per side. */
|
||||
const SCOREBOARD_PLAYERS_PER_TEAM = 4;
|
||||
|
||||
/**
|
||||
* Maps a game's ingested scoreboard (stored winner-first) onto the
|
||||
* alpha/bravo-oriented shape the match timeline renders.
|
||||
*/
|
||||
/** Maps a game's ingested scoreboard (stored winner-first) onto the alpha/bravo shape the timeline renders. */
|
||||
export function resolveTimelineScoreboard(
|
||||
data: IngestedScoreboardData | undefined,
|
||||
alphaIsWinner: boolean,
|
||||
@@ -48,17 +45,12 @@ export function resolveTimelineScoreboard(
|
||||
}
|
||||
|
||||
/**
|
||||
* One team's weapons for a map row: each roster member's reported weapon,
|
||||
* with the gaps filled from the map's ingested scoreboard rows that no
|
||||
* member accounts for. An ingested row without a user is only unaccounted
|
||||
* for if no roster member already reported its weapon, otherwise it is that
|
||||
* member's row and reusing it would show their weapon twice — a multiset
|
||||
* count, so two ingested rows of a weapon survive one report of it.
|
||||
* One team's weapons for a map row: each roster member's reported weapon, gaps filled from the
|
||||
* ingested scoreboard rows no member accounts for. A userless ingested row counts as accounted
|
||||
* for once a member reported its weapon (multiset count), else the weapon would show twice.
|
||||
*
|
||||
* @param linkedWeapons per roster member, the weapon they reported for the map (null = none)
|
||||
* @param ingestedPlayers the map's ingested scoreboard rows (empty when none ingested)
|
||||
* @param linkedWeapons per roster member (null = none); result is index-aligned, fills marked unverified
|
||||
* @param tournamentTeamId the team's side id in the game result (tournament team or SendouQ group id)
|
||||
* @returns index-aligned with `linkedWeapons`; ingested fills are marked unverified
|
||||
*/
|
||||
export function resolveTimelineWeapons({
|
||||
linkedWeapons,
|
||||
|
||||
@@ -5,14 +5,9 @@ import { weaponReportActionSchema } from "./match-page-schemas";
|
||||
import type { WeaponReporterMap, WeaponReporterProps } from "./WeaponReporter";
|
||||
|
||||
/**
|
||||
* Wires the `<WeaponReporter />` component to the standard
|
||||
* `REPORT_WEAPON` / `UNDO_WEAPON_REPORT` fetcher actions and to the
|
||||
* locally persisted recently-reported weapons list.
|
||||
*
|
||||
* `maps` is the maps the viewer can report a weapon for, in play order, each
|
||||
* carrying its `mapIndex` in the match's map list — a viewer who sat out a map
|
||||
* simply has no entry for it. `pastReported` is the weapons the viewer has
|
||||
* already reported, paired with the `mapIndex` they were reported for.
|
||||
* Wires `<WeaponReporter />` to the `REPORT_WEAPON` / `UNDO_WEAPON_REPORT` actions and the
|
||||
* locally persisted recently-reported list. `maps` are the maps the viewer can report for, in
|
||||
* play order with their `mapIndex` in the match's map list (sat out maps have no entry).
|
||||
*/
|
||||
export function useMatchWeaponReport({
|
||||
maps,
|
||||
|
||||
@@ -16,10 +16,8 @@ export interface InferredSubstitution {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the rosters of two consecutive maps and pairs up any
|
||||
* players that dropped from a side with new players that joined the same side.
|
||||
* The pairs are returned in roster order, so the first player out is paired with
|
||||
* the first new player in. When the counts don't match, unpaired players are ignored.
|
||||
* Pairs players that dropped from a side between two consecutive maps with the new players that
|
||||
* joined it, in roster order. Unpaired players are ignored when the counts don't match.
|
||||
*/
|
||||
export function inferSubstitutions(
|
||||
previousRosters: Rosters,
|
||||
@@ -59,11 +57,7 @@ const NUM_MAP = {
|
||||
"0": ["0", "8"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed.
|
||||
*
|
||||
* Given the same seed, this function will always return the same password.
|
||||
*/
|
||||
/** Deterministic 4-digit private battle room password for the seed. */
|
||||
export function resolveRoomPass(seed: number | string) {
|
||||
let pass = "5";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
@@ -75,8 +69,7 @@ export function resolveRoomPass(seed: number | string) {
|
||||
pass += next;
|
||||
}
|
||||
|
||||
// prevent 5555 since many use it as a default pass
|
||||
// making it a bit more common guess
|
||||
// 5555 is a common default pass, so a more common guess
|
||||
if (pass === "5555") return "5800";
|
||||
|
||||
return pass;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
const PENALTY_BRIDGE_SECONDS = 6;
|
||||
|
||||
/**
|
||||
* Width of the label gutter left of the plot area, shared by the objective
|
||||
* chart (its y-axis is forced to this width) and the player-status rows (their
|
||||
* weapon-icon column), so both plots span exactly the same x-range.
|
||||
*/
|
||||
/** Label gutter left of the plot, shared by the objective chart's y-axis and the player-status weapon column so both span the same x-range. */
|
||||
export const TIMELINE_PLOT_GUTTER_PX = 36;
|
||||
|
||||
/** The count a knockout wins at: the counter runs out and the team takes all of it. */
|
||||
@@ -18,14 +14,11 @@ export interface PenaltyRead {
|
||||
}
|
||||
|
||||
/**
|
||||
* The penalty pill is misread for a frame or two at a time: it flickers
|
||||
* between a value and null, and occasionally drops a digit ("36" read as
|
||||
* "6"). Median-filters isolated outlier values, drops one-off reads with no
|
||||
* nearby confirmation and carries the previous value across short null gaps
|
||||
* so the band renders as one steady shape instead of a picket fence.
|
||||
* The penalty pill flickers between a value and null and occasionally drops a digit ("36" → "6").
|
||||
* Median-filters outliers, drops one-off reads with no nearby confirmation and carries the previous
|
||||
* value across short null gaps so the band renders as one steady shape.
|
||||
*
|
||||
* @param reads one team's penalty reads, sorted by `t` ascending
|
||||
* @returns the smoothed penalty per read, index-aligned with the input
|
||||
* @param reads one team's reads sorted by `t` ascending; result is index-aligned
|
||||
*/
|
||||
export function smoothPenalties(
|
||||
reads: readonly PenaltyRead[],
|
||||
@@ -69,15 +62,11 @@ export interface ObjectiveScoreRead {
|
||||
}
|
||||
|
||||
/**
|
||||
* Match scores implied by each team's last readable counter read. The counter
|
||||
* counts down from 100 while match scores run the other way (100 = knockout),
|
||||
* so a read is inverted into the count the team took. Stands in where the
|
||||
* results screen reports no score of its own — a knockout's loser — but the
|
||||
* last read is only as late as the last frame the counter was seen in, so it
|
||||
* can trail the count the team ended on.
|
||||
* Match scores (0-100, null if nothing read) implied by each team's last readable counter read,
|
||||
* inverted since the counter counts down. Stands in where the results screen has no score (a
|
||||
* knockout's loser), but can trail the final count since it only goes as far as the counter was last seen.
|
||||
*
|
||||
* @param reads counter reads, in any order
|
||||
* @returns per-team match score (0-100); null where nothing was read
|
||||
* @param reads in any order
|
||||
*/
|
||||
export function matchScoresFromObjective(
|
||||
reads: readonly ObjectiveScoreRead[],
|
||||
@@ -97,10 +86,7 @@ export function matchScoresFromObjective(
|
||||
return [lastCountTaken(0), lastCountTaken(1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds into the source formatted for display: m:ss, growing an hours
|
||||
* part only when needed.
|
||||
*/
|
||||
/** Seconds formatted as m:ss, with an hours part only when needed. */
|
||||
export function formatElapsed(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
@@ -3,11 +3,7 @@ import * as v from "valibot";
|
||||
const TRUTHY_ENV_VALUES = ["true", "1", "yes", "on", "y", "enabled"];
|
||||
const FALSY_ENV_VALUES = ["false", "0", "no", "off", "n", "disabled"];
|
||||
|
||||
/**
|
||||
* Builds an `Error` with a readable, multi-line message describing every invalid
|
||||
* environment variable. Schemas are keyed by the literal env var name so the
|
||||
* issue path points straight at the variable a contributor needs to fix.
|
||||
*/
|
||||
/** `Error` listing every invalid env var; issue paths are the literal variable names. */
|
||||
export function formatEnvErrors(
|
||||
scope: "client" | "server",
|
||||
issues: readonly v.BaseIssue<unknown>[],
|
||||
@@ -24,15 +20,10 @@ export function formatEnvErrors(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* String schema that must be set to a non-empty value in production, but falls
|
||||
* back to `devFallback` outside of production so contributors can run the app
|
||||
* without configuring every integration.
|
||||
*/
|
||||
/** Non-empty string in production, `devFallback` elsewhere. */
|
||||
export function requiredInProd(isProd: boolean, devFallback: string) {
|
||||
// The production branch defaults to `""` rather than being required outright
|
||||
// so that a missing variable reaches `minLength` and reports the same
|
||||
// actionable message an empty one does, instead of valibot's "Invalid key".
|
||||
// defaults to `""` so a missing variable reaches `minLength` and gets the same
|
||||
// actionable message as an empty one, instead of valibot's "Invalid key"
|
||||
return isProd
|
||||
? v.pipe(
|
||||
v.optional(v.string(), ""),
|
||||
|
||||
@@ -4,13 +4,8 @@ import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
import { superRefine, type ValidationCtx } from "./utils/schema";
|
||||
|
||||
/**
|
||||
* Server (`process.env`) configuration. Import with
|
||||
* `import { ServerConfig } from "~/config.server"` and read values like
|
||||
* `ServerConfig.dbPath` or `ServerConfig.storage.endpoint`.
|
||||
*
|
||||
* Values are validated once when this module is first imported, surfacing a
|
||||
* single clear error for any misconfigured variable. Variables required in
|
||||
* production fall back to development defaults outside of production.
|
||||
* Server (`process.env`) configuration, validated once on first import. Variables required in
|
||||
* production fall back to development defaults elsewhere.
|
||||
*/
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN;
|
||||
@@ -41,9 +36,8 @@ const schema = v.pipe(
|
||||
|
||||
PATREON_ACCESS_TOKEN: v.optional(v.string()),
|
||||
|
||||
// The VAPID public key (VITE_VAPID_PUBLIC_KEY) lives in `~/config` since
|
||||
// it is client-readable; the full three-var coupling is completed by the
|
||||
// runtime check in webPush.server.ts.
|
||||
// the client-readable public key (VITE_VAPID_PUBLIC_KEY) lives in `~/config`;
|
||||
// webPush.server.ts checks all three are set together
|
||||
VAPID_PRIVATE_KEY: v.optional(v.string()),
|
||||
VAPID_EMAIL: v.optional(v.string()),
|
||||
}),
|
||||
@@ -60,34 +54,23 @@ if (!parsed.success) {
|
||||
const values = parsed.output;
|
||||
|
||||
export const ServerConfig = {
|
||||
/**
|
||||
* Whether `NODE_ENV` is `"production"`. Note: this is `true` during e2e tests
|
||||
* (which run a production build), so combine it with `IS_E2E_TEST_RUN` when
|
||||
* you specifically need to exclude the e2e environment (as the session
|
||||
* cookies do).
|
||||
*/
|
||||
/** Also `true` during e2e tests (production build); combine with `IS_E2E_TEST_RUN` to exclude them. */
|
||||
isProduction: values.NODE_ENV === "production",
|
||||
/** Whether the app is running under the test runner. */
|
||||
isTest: values.NODE_ENV === "test",
|
||||
|
||||
/** Path to the SQLite database file. */
|
||||
dbPath: values.DB_PATH,
|
||||
/** Secret used to sign session cookies. */
|
||||
sessionSecret: values.SESSION_SECRET,
|
||||
/** Token authorizing internal Lohi (bot/cron) requests. */
|
||||
/** Authorizes internal Lohi (bot/cron) requests. */
|
||||
lohiToken: values.LOHI_TOKEN,
|
||||
/** SQL query logging level. */
|
||||
sqlLog: values.SQL_LOG,
|
||||
/** Whether response caching is disabled. */
|
||||
disableCache: values.DISABLE_CACHE,
|
||||
|
||||
/** Discord OAuth configuration. */
|
||||
discord: {
|
||||
clientId: values.DISCORD_CLIENT_ID,
|
||||
clientSecret: values.DISCORD_CLIENT_SECRET,
|
||||
},
|
||||
|
||||
/** S3-compatible object storage configuration. */
|
||||
/** S3-compatible object storage */
|
||||
storage: {
|
||||
endpoint: values.STORAGE_END_POINT,
|
||||
accessKey: values.STORAGE_ACCESS_KEY,
|
||||
@@ -96,18 +79,17 @@ export const ServerConfig = {
|
||||
bucket: values.STORAGE_BUCKET,
|
||||
},
|
||||
|
||||
/** Twitch integration credentials. Optional — streams are hidden when unset. */
|
||||
/** Optional, streams are hidden when unset. */
|
||||
twitch: {
|
||||
clientId: values.TWITCH_CLIENT_ID,
|
||||
clientSecret: values.TWITCH_CLIENT_SECRET,
|
||||
},
|
||||
|
||||
/** Patreon integration configuration. */
|
||||
patreon: {
|
||||
accessToken: values.PATREON_ACCESS_TOKEN,
|
||||
},
|
||||
|
||||
/** Web push (VAPID) server configuration. */
|
||||
/** web push */
|
||||
vapid: {
|
||||
privateKey: values.VAPID_PRIVATE_KEY,
|
||||
email: values.VAPID_EMAIL,
|
||||
|
||||
@@ -3,16 +3,11 @@ import { envBoolean, formatEnvErrors, requiredInProd } from "./config-helpers";
|
||||
import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
|
||||
/**
|
||||
* Client (`VITE_*`) configuration. Import with `import { Config } from "~/config"`
|
||||
* and read values like `Config.siteDomain` or `Config.staticAssetsUrl`.
|
||||
*
|
||||
* Values are validated once when this module is first imported, surfacing a
|
||||
* single clear error for any misconfigured variable. Variables required in
|
||||
* production fall back to development defaults outside of production.
|
||||
* Client (`VITE_*`) configuration, validated once on first import. Variables required in
|
||||
* production fall back to development defaults elsewhere.
|
||||
*/
|
||||
|
||||
// `import.meta.env` is undefined when Playwright bundles test code, so guard the
|
||||
// access and treat that environment as non-production (see `~/utils/e2e`).
|
||||
// `import.meta.env` is undefined when Playwright bundles test code; treat that as non-production (see `~/utils/e2e`)
|
||||
const env =
|
||||
typeof import.meta.env !== "undefined"
|
||||
? (import.meta.env as Record<string, string | undefined>)
|
||||
@@ -42,9 +37,8 @@ const schema = v.object({
|
||||
VITE_LEAGUE_GOOGLE_FORM_URL: v.optional(v.string()),
|
||||
VITE_SHOW_BANNER_FOR_SEASON: v.optional(v.string()),
|
||||
|
||||
// The VAPID private key and email live in `~/config.server` since they are
|
||||
// server-only; the full three-var coupling is completed by the runtime check
|
||||
// in webPush.server.ts.
|
||||
// the server-only private key and email live in `~/config.server`;
|
||||
// webPush.server.ts checks all three are set together
|
||||
VITE_VAPID_PUBLIC_KEY: v.optional(v.string()),
|
||||
});
|
||||
|
||||
@@ -55,24 +49,20 @@ if (!parsed.success) {
|
||||
const values = parsed.output;
|
||||
|
||||
export const Config = {
|
||||
/** Base URL of the site, e.g. `https://sendou.ink`. */
|
||||
/** e.g. `https://sendou.ink` */
|
||||
siteDomain: values.VITE_SITE_DOMAIN,
|
||||
/** Filename of the default tournament logo asset. */
|
||||
tournamentDefaultLogo: values.VITE_TOURNAMENT_DEFAULT_LOGO,
|
||||
/** Base URL for static assets (images, sounds, svg). */
|
||||
staticAssetsUrl: values.VITE_STATIC_ASSETS_URL,
|
||||
/** Whether to use real seasons & league data (used when developing against the production database). */
|
||||
/** Use real seasons & league data (when developing against the production database). */
|
||||
prodMode: values.VITE_PROD_MODE,
|
||||
/** Whether to show the LUTI navigation item. */
|
||||
showLutiNavItem: values.VITE_SHOW_LUTI_NAV_ITEM,
|
||||
fuseEnabled: values.VITE_FUSE_ENABLED,
|
||||
/** Whether the scanner is available to everyone. While false only the admin and devs can use the scanner page and its ingest endpoint. */
|
||||
/** While false only the admin and devs can use the scanner page and its ingest endpoint. */
|
||||
scannerEnabled: values.VITE_SCANNER_ENABLED,
|
||||
/** Google Form URL for league registration, if configured. */
|
||||
leagueGoogleFormUrl: values.VITE_LEAGUE_GOOGLE_FORM_URL,
|
||||
/** Season identifier to show the registration banner for, if any. */
|
||||
/** Season to show the registration banner for. */
|
||||
showBannerForSeason: values.VITE_SHOW_BANNER_FOR_SEASON,
|
||||
/** Web push (VAPID) client configuration. */
|
||||
/** web push */
|
||||
vapid: {
|
||||
publicKey: values.VITE_VAPID_PUBLIC_KEY,
|
||||
},
|
||||
|
||||
@@ -14,11 +14,8 @@ import {
|
||||
} from "kysely";
|
||||
|
||||
/**
|
||||
* Makes inserting an empty array of values a no-op instead of a syntax error.
|
||||
* Kysely compiles `.values([])` into invalid SQL, so without this plugin every
|
||||
* dynamic multi-row insert would need a length check before it. The empty
|
||||
* insert is rewritten into `INSERT INTO "T" SELECT * FROM "T" WHERE 0` which
|
||||
* inserts zero rows and returns zero rows for any `returning` clause.
|
||||
* Kysely compiles `.values([])` into invalid SQL; this rewrites it into `INSERT INTO "T" SELECT * FROM "T" WHERE 0`,
|
||||
* inserting and returning zero rows.
|
||||
*/
|
||||
export class EmptyValuesNoopPlugin implements KyselyPlugin {
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* Every "Table.column" whose text content is a JSON document. The node-sqlite
|
||||
* dialect parses only these columns (plus computed expression columns) when
|
||||
* reading rows; all other text columns stay plain strings even if a user typed
|
||||
* something JSON-shaped into them. Kept in sync with the JSONColumnType
|
||||
* declarations of tables.ts by json-columns.test.ts.
|
||||
* Every "Table.column" the dialect parses as JSON; other text stays a plain string even if JSON-shaped.
|
||||
* Kept in sync with the JSONColumnType declarations of tables.ts by json-columns.test.ts.
|
||||
*/
|
||||
export const JSON_COLUMNS: ReadonlySet<string> = new Set([
|
||||
"AllTeam.customTheme",
|
||||
|
||||
@@ -25,8 +25,7 @@ describe("computedJsonColumns", () => {
|
||||
).as("weapons"),
|
||||
]);
|
||||
|
||||
// `username` is `coalesce("User"."tournamentName", "User"."username")`, which
|
||||
// SQLite reports the same way as the weapons subquery: as a computed column
|
||||
// `username` is a `coalesce(...)`, reported by SQLite as a computed column like the weapons subquery
|
||||
expect(computedJsonColumns(query.compile().query)).toEqual(
|
||||
new Set(["weapons"]),
|
||||
);
|
||||
@@ -101,8 +100,7 @@ describe("reading rows", () => {
|
||||
.where("User.id", "=", member.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// rendered as a bare JSX child on public tournament pages, so an object here
|
||||
// is "Objects are not valid as a React child" for everyone viewing them
|
||||
// rendered as a bare JSX child on public pages; an object here would crash React for every viewer
|
||||
expect(row.username).toBe(JSON_SHAPED_TEXT);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ const NO_NAMES: ReadonlySet<string> = new Set();
|
||||
export function computedJsonColumns(
|
||||
query: RootOperationNode,
|
||||
): ReadonlySet<string> {
|
||||
// only select queries have computed result columns: `returning` selections keep
|
||||
// the origin metadata of the column they write to
|
||||
// `returning` selections keep the origin metadata of the column they write to
|
||||
return SelectQueryNode.is(query) ? outputNames(query) : NO_NAMES;
|
||||
}
|
||||
|
||||
@@ -90,8 +89,7 @@ function outputNames(select: SelectQueryNode): ReadonlySet<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// a compound select is named after its first branch, but any branch can be the
|
||||
// one contributing the JSON document
|
||||
// a compound select is named after its first branch, but any branch can contribute the JSON
|
||||
for (const { expression } of select.setOperations ?? []) {
|
||||
if (!SelectQueryNode.is(expression)) continue;
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
SqliteQueryCompiler,
|
||||
} from "kysely";
|
||||
|
||||
/**
|
||||
* Query kinds whose compiled SQL is stable enough to keep a prepared statement
|
||||
* around for. Everything else (DDL, raw SQL, `begin`/`commit`) is prepared fresh.
|
||||
*/
|
||||
/** Query kinds worth caching a prepared statement for; DDL, raw SQL and `begin`/`commit` are prepared fresh. */
|
||||
const CACHEABLE_QUERY_KINDS = new Set([
|
||||
"SelectQueryNode",
|
||||
"InsertQueryNode",
|
||||
@@ -34,11 +31,7 @@ const CACHEABLE_QUERY_KINDS = new Set([
|
||||
"MergeQueryNode",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Leading keywords of raw statements that can not change the schema, so the
|
||||
* column lists the statement cache is holding stay valid across them. Raw DDL
|
||||
* (`create`, `alter`, `drop`, ...) is not here and clears the cache.
|
||||
*/
|
||||
/** Leading keywords of raw statements that can't change the schema; any other raw statement clears the cache. */
|
||||
const SCHEMA_PRESERVING_RAW_COMMANDS = new Set([
|
||||
"begin",
|
||||
"commit",
|
||||
@@ -62,39 +55,24 @@ const NO_JSON_OUTPUT_NAMES: ReadonlySet<string> = new Set();
|
||||
|
||||
export interface NodeSqliteDialectConfig {
|
||||
database: DatabaseSync;
|
||||
/**
|
||||
* Keeps prepared statements around between queries, keyed by their SQL. Saves
|
||||
* a re-compile per query at the cost of holding onto the compiled programs.
|
||||
* Off by default because it assumes the schema does not change under the
|
||||
* connection, which is not true while migrations run.
|
||||
*/
|
||||
/** Caches prepared statements by SQL. Off by default since it assumes a stable schema, untrue while migrations run. */
|
||||
cacheStatements?: boolean;
|
||||
/**
|
||||
* "Table.column" names whose text content is a JSON document. When given,
|
||||
* result values of these columns are parsed into objects. Other text columns
|
||||
* are always returned verbatim, so JSON-shaped user input stays a string.
|
||||
* Origin metadata from `statement.columns()` sees through aliases, views,
|
||||
* subqueries and CTEs, so the names here are the underlying table names
|
||||
* (e.g. `AllTeam`, not the `Team` view).
|
||||
* "Table.column" names parsed as JSON; other text stays verbatim so JSON-shaped user input stays a string.
|
||||
* Origin metadata sees through aliases, views, subqueries and CTEs, so use underlying table names (`AllTeam`, not `Team`).
|
||||
*/
|
||||
jsonColumns?: ReadonlySet<string>;
|
||||
/**
|
||||
* Output names of a query's computed result columns whose value is a JSON
|
||||
* document, which is what `jsonArrayFrom`/`jsonObjectFrom` subqueries compile
|
||||
* to. SQLite reports no origin for a computed expression, so only the query's
|
||||
* own AST tells those apart from an ordinary `coalesce(...)` over user text.
|
||||
* Output names of computed JSON result columns (`jsonArrayFrom`/`jsonObjectFrom` subqueries). SQLite reports no
|
||||
* origin for computed expressions, so only the AST tells them from a `coalesce(...)` over user text.
|
||||
* Called once per prepared statement. Requires {@link jsonColumns}.
|
||||
*/
|
||||
computedJsonColumns?: (query: RootOperationNode) => ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kysely dialect backed by Node's built-in `node:sqlite` module, replacing the
|
||||
* `better-sqlite3` native addon that Kysely's own `SqliteDialect` expects.
|
||||
*
|
||||
* Rows come back from `node:sqlite` as arrays rather than objects: the objects it
|
||||
* builds itself are both slower to produce and have a `null` prototype, which is
|
||||
* not what the rest of the codebase (or Kysely's own dialects) hand out.
|
||||
* Kysely dialect over `node:sqlite` instead of the `better-sqlite3` addon. Rows are read as arrays:
|
||||
* the objects `node:sqlite` builds are slower and have a `null` prototype.
|
||||
*/
|
||||
export class NodeSqliteDialect implements Dialect {
|
||||
readonly #config: NodeSqliteDialectConfig;
|
||||
@@ -252,8 +230,7 @@ class NodeSqliteConnection implements DatabaseConnection {
|
||||
);
|
||||
}
|
||||
|
||||
// deliberately uncached: the cursor stays open across yields, so sharing the
|
||||
// statement with another query would reset it mid-iteration
|
||||
// uncached: the cursor stays open across yields, sharing the statement would reset it mid-iteration
|
||||
const prepared = prepare(
|
||||
this.#database,
|
||||
compiledQuery.sql,
|
||||
@@ -342,8 +319,7 @@ function columnMetadata(
|
||||
columnNames: columns.map((it) => it.name),
|
||||
jsonColumnFlags: columns.map((it) => {
|
||||
if (!jsonColumns) return false;
|
||||
// a null origin is a computed expression (a jsonArrayFrom subquery, but also
|
||||
// e.g. a coalesce over user text), which only the query itself can classify
|
||||
// null origin = computed expression (jsonArrayFrom subquery, or a coalesce over user text)
|
||||
if (it.column === null) return jsonColumns.byOutputName.has(it.name);
|
||||
return jsonColumns.byOrigin.has(`${it.table}.${it.column}`);
|
||||
}),
|
||||
@@ -360,8 +336,7 @@ function readRows<R>(
|
||||
|
||||
if (rawRows.length === 0) return [];
|
||||
|
||||
// `select *` widens when a migration adds a column, leaving a cached statement
|
||||
// with a stale column list until the next read notices the mismatch
|
||||
// `select *` widens when a migration adds a column, leaving a cached statement with a stale column list
|
||||
if (rawRows[0].length !== prepared.columnNames.length) {
|
||||
Object.assign(
|
||||
prepared,
|
||||
@@ -413,11 +388,7 @@ function parseJsonValue(value: string): unknown {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the raw document could hold a `__proto__` or `constructor` key. Skips the
|
||||
* recursive walk for the vast majority of documents; a `\u` escape can spell either
|
||||
* name in a way the substring checks would miss, so those take the walk too.
|
||||
*/
|
||||
/** Could the raw document hold a `__proto__`/`constructor` key? `\u` escapes could spell either, so those walk too. */
|
||||
function mayPrototypePollute(value: string) {
|
||||
return (
|
||||
value.includes("__proto__") ||
|
||||
|
||||
@@ -3,13 +3,8 @@ import { deleteAllRows } from "~/db/wipe";
|
||||
import { markDatabaseClean } from "~/db/write-tracker";
|
||||
|
||||
/**
|
||||
* Resets all data in the database by deleting all rows from every table,
|
||||
* except for SQLite system tables and the kysely migration bookkeeping tables
|
||||
* (`kysely_migration` and `kysely_migration_lock`).
|
||||
*
|
||||
* Tests do not call this — `app/test-setup.ts` runs it after every vitest test that
|
||||
* wrote anything, and the e2e reset fixture before every test. Call it by hand only
|
||||
* to wipe *within* a test.
|
||||
* Deletes all rows except migration bookkeeping. `app/test-setup.ts` runs it after every writing
|
||||
* vitest test and the e2e reset fixture before every test; call by hand only to wipe *within* a test.
|
||||
*/
|
||||
export const dbReset = async () => {
|
||||
await deleteAllRows();
|
||||
|
||||
@@ -35,8 +35,7 @@ const CANONICAL_MAIN_WEAPON_IDS = mainWeaponIds.filter(
|
||||
(id) => canonicalWeaponSplId(id) === id,
|
||||
);
|
||||
|
||||
/** `count` main weapons distinct down to their canonical id, e.g. a weapon pool or
|
||||
* a multi-weapon build's. */
|
||||
/** `count` main weapons distinct down to their canonical id. */
|
||||
export function mainWeapons(count: number): MainWeaponId[] {
|
||||
return faker.helpers.arrayElements(CANONICAL_MAIN_WEAPON_IDS, count);
|
||||
}
|
||||
@@ -50,10 +49,7 @@ export function gear() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-game name with its discriminator, e.g. `Agent 4#1859`. `name` is sanitized and
|
||||
* truncated the way the real thing is, so the result always passes `inGameNameIsValid`.
|
||||
*/
|
||||
/** E.g. `Agent 4#1859`; `name` is sanitized and truncated so the result always passes `inGameNameIsValid`. */
|
||||
export function inGameName(name = faker.person.firstName()): string {
|
||||
const discriminator = faker.string.alphanumeric({
|
||||
length: {
|
||||
@@ -66,10 +62,7 @@ export function inGameName(name = faker.person.firstName()): string {
|
||||
return `${sanitizedName(name)}#${discriminator}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A map list of `count` maps, rotating through the ranked modes and never repeating
|
||||
* a stage, the way a real one looks. Callers add whatever `source` their domain uses.
|
||||
*/
|
||||
/** `count` maps rotating through the ranked modes, never repeating a stage. Callers add their own `source`. */
|
||||
export function mapList(count: number): ModeWithStage[] {
|
||||
const stages = faker.helpers.arrayElements(stageIds, count);
|
||||
|
||||
@@ -79,10 +72,7 @@ export function mapList(count: number): ModeWithStage[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The abilities of a build: a main and three subs per gear slot. All of them are
|
||||
* stackable ones, which every slot allows.
|
||||
*/
|
||||
/** A main and three subs per gear slot, all stackable so every slot allows them. */
|
||||
export function buildAbilities(): BuildAbilitiesTuple {
|
||||
return [gearAbilities(), gearAbilities(), gearAbilities()];
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import {
|
||||
userAsyncLocalStorage,
|
||||
} from "~/features/auth/core/user-context.server";
|
||||
|
||||
/**
|
||||
* Runs `fn` inside the acting-user store, so that repository functions resolving
|
||||
* the actor via `actorId()` see `userId` as the acting user. Needed because seeding
|
||||
* happens outside a request, where there is no acting user at all.
|
||||
*/
|
||||
/** Runs `fn` with `userId` as the `actorId()` actor, since seeding happens outside a request. */
|
||||
export function actAs<T>(userId: number, fn: () => T): T {
|
||||
return userAsyncLocalStorage.run(
|
||||
{ user: { id: userId } as AuthenticatedUser },
|
||||
|
||||
@@ -14,11 +14,7 @@ type TimestampColumn<T extends BackdatableTable> = Extract<
|
||||
`${string}At`
|
||||
>;
|
||||
|
||||
/**
|
||||
* Moves a row's timestamps into the past. Every production write stamps *now*, so
|
||||
* a seed that needs rows looking old — an expired vote, a season's worth of matches
|
||||
* — has no way to ask for one.
|
||||
*/
|
||||
/** Moves a row's timestamps into the past, since every production write stamps *now*. */
|
||||
export async function backdate<T extends BackdatableTable>(
|
||||
table: T,
|
||||
id: number,
|
||||
@@ -27,7 +23,7 @@ export async function backdate<T extends BackdatableTable>(
|
||||
const assignments: RawBuilder<unknown>[] = [];
|
||||
|
||||
for (const [column, date] of Object.entries(timestamps)) {
|
||||
// so that a caller passing its own optional dates through needs no filtering
|
||||
// callers pass their own optional dates through unfiltered
|
||||
if (!date) continue;
|
||||
|
||||
assignments.push(
|
||||
|
||||
@@ -42,19 +42,10 @@ export type Factory<Args, Row, Defaults, Options> = {
|
||||
const sequenceResets = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Defines a factory: a thin wrapper around a repository write function that fills
|
||||
* arguments with a plausible default and lets the caller override any of them.
|
||||
*
|
||||
* `Args` is inferred from `insert`, so factories never restate column types. What
|
||||
* `defaults` leaves out — foreign keys above all, which a factory must not invent —
|
||||
* becomes a required argument of `create`.
|
||||
*
|
||||
* Defaults are drawn eagerly, before overrides are applied, so that which fields a
|
||||
* caller happens to override does not shift the values every later row gets.
|
||||
*
|
||||
* `applyOptions` runs after the insert and is how a factory hands back a row in a
|
||||
* later state (a concluded match, a finalized tournament). It gets there by running
|
||||
* the app's own operations, never by writing the resulting rows itself.
|
||||
* Wraps a repository write with plausible defaults; `Args` is inferred from `insert` and whatever `defaults`
|
||||
* leaves out (foreign keys above all) is required by `create`. Defaults are drawn eagerly, before overrides,
|
||||
* so overriding doesn't shift later rows. `applyOptions` runs after the insert to reach later states through
|
||||
* the app's own operations, never by writing rows itself.
|
||||
*/
|
||||
export function defineFactory<
|
||||
Args,
|
||||
@@ -86,8 +77,7 @@ export function defineFactory<
|
||||
return row;
|
||||
};
|
||||
|
||||
// `create` requires everything `defaults` doesn't supply, so the merge is a
|
||||
// complete `Args` — something the compiler can't work out from the spread
|
||||
// the merge is a complete `Args`, which the compiler can't work out from the spread
|
||||
const build = (overrides: Partial<Args>) =>
|
||||
({
|
||||
...defaults?.({ seq: ++seq }),
|
||||
|
||||
@@ -3,10 +3,7 @@ import { base, en, Faker } from "@faker-js/faker";
|
||||
const FAKER_SEED = 5800;
|
||||
const MAX_UNIQUE_ATTEMPTS = 100;
|
||||
|
||||
/**
|
||||
* Faker instance dedicated to seeding. Deliberately not the global singleton, so
|
||||
* that app code or a test drawing from `faker` cannot shift what the seed produces.
|
||||
*/
|
||||
/** Not the global singleton, so app code or a test drawing from `faker` can't shift what the seed produces. */
|
||||
export const faker = new Faker({ locale: [en, base] });
|
||||
faker.seed(FAKER_SEED);
|
||||
|
||||
@@ -25,11 +22,7 @@ export function createSeededFaker(
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws from `generate` until it produces a value that has not been drawn before,
|
||||
* for values that should look real but still be unique (e.g. a Discord name).
|
||||
* Values with a unique constraint should be derived from the factory's `seq` instead.
|
||||
*/
|
||||
/** Draws from `generate` until unique (e.g. a Discord name). Unique-constrained values should derive from `seq` instead. */
|
||||
export function unique<T>(generate: () => T): T {
|
||||
for (let attempt = 0; attempt < MAX_UNIQUE_ATTEMPTS; attempt++) {
|
||||
const value = generate();
|
||||
|
||||
@@ -2,12 +2,8 @@ import { sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
|
||||
/**
|
||||
* Moves a user to a fixed id. Production permission logic keys off literal user
|
||||
* ids (`ADMIN_ID`, `STAFF_IDS`), so the users those refer to have to land on them
|
||||
* for the app to consider them an admin or staff at all.
|
||||
*
|
||||
* Throws if the id is already taken, since taking it would mean deleting somebody
|
||||
* else's rows. Create the pinned users before any other.
|
||||
* Moves a user to a fixed id, since permission logic keys off literal ids (`ADMIN_ID`, `STAFF_IDS`).
|
||||
* Throws if the id is taken, so create pinned users before any other.
|
||||
*/
|
||||
export async function pinUserId(userId: number, pinnedId: number) {
|
||||
if (userId === pinnedId) return pinnedId;
|
||||
@@ -29,8 +25,7 @@ export async function pinUserId(userId: number, pinnedId: number) {
|
||||
db,
|
||||
);
|
||||
|
||||
// the search index is kept in sync by triggers that don't watch `id`, so its
|
||||
// entry would keep pointing at the id the user just moved off of
|
||||
// the search index triggers don't watch `id`, so its entry would keep pointing at the old id
|
||||
await sql`insert into "UserSearch"("UserSearch") values ('rebuild')`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { db } from "~/db/sql";
|
||||
|
||||
/**
|
||||
* Moves every season stamped row (skills and the aggregated stats keyed off them)
|
||||
* to the given season. Concluding a match stamps its results with the season that
|
||||
* is current then, so matches backdated into an older season still leave their
|
||||
* results in the ongoing one — a seed that needs a season looking played out and
|
||||
* over has no other way to ask for one.
|
||||
* Moves every season stamped row (skills and aggregated stats) to `season`. Concluding stamps the current
|
||||
* season, so backdated matches still leave their results in the ongoing one.
|
||||
*/
|
||||
export async function reseason(season: number) {
|
||||
await db.updateTable("Skill").set({ season }).execute();
|
||||
|
||||
@@ -24,8 +24,7 @@ export const CUSTOM_NAMES = [
|
||||
"xX_sniper_Xx",
|
||||
];
|
||||
|
||||
/** Kana-only in-game names — the Switch keyboard allows no kanji or emoji, so a
|
||||
* kanji display name pairs with one of these. */
|
||||
/** The Switch keyboard allows no kanji or emoji, so a kanji display name pairs with one of these. */
|
||||
const KANA_NAMES = [
|
||||
"スプラちゃん",
|
||||
"いかタコどん",
|
||||
|
||||
@@ -53,11 +53,8 @@ const EVENINGS: WeekSchedule = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Availability of the admin's team, their friends and a stranger, for this week
|
||||
* and the next. Every state the schedule surfaces can be in is on the admin's
|
||||
* team: a filled week, a week submitted as unavailable, a week nobody reported,
|
||||
* ranges crossing midnight, day notes, and ranges a tournament or a booked scrim
|
||||
* takes back.
|
||||
* The admin's team, friends and a stranger, this week and next. The admin's team covers every state: a filled
|
||||
* week, one submitted as unavailable, one nobody reported, midnight-crossing ranges, day notes, commitments.
|
||||
*/
|
||||
export async function seedAvailability({
|
||||
users,
|
||||
@@ -76,16 +73,14 @@ export async function seedAvailability({
|
||||
const [, multiRangeId, crossMidnightId, unavailableId, weekendId] =
|
||||
teams.allianceRogue.playerUserIds;
|
||||
|
||||
// the tournament and the scrim the admin's team is committed to, with room
|
||||
// around them so that the commitment visibly takes availability back
|
||||
// with room around them so the commitment visibly takes availability back
|
||||
const commitments = [
|
||||
{
|
||||
userId: users.adminId,
|
||||
startsAt: scrims.accepted.startsAt - HOUR,
|
||||
endsAt: scrims.accepted.startsAt + 2 * HOUR,
|
||||
},
|
||||
// registration availability of the reg open tournament: fully available,
|
||||
// available from an hour in, and not available at all
|
||||
// reg open tournament: fully available, available from an hour in, not available at all
|
||||
{
|
||||
userId: users.adminId,
|
||||
startsAt: tournaments.regOpen.startsAt - HOUR,
|
||||
@@ -104,8 +99,7 @@ export async function seedAvailability({
|
||||
];
|
||||
|
||||
const schedules: Array<SeededSchedule> = [
|
||||
// N-ZAP reports nothing at all, so that they are the one the Monday
|
||||
// reminder routine has something to say to
|
||||
// N-ZAP reports nothing, so the Monday reminder routine has someone to nudge
|
||||
{
|
||||
userId: users.adminId,
|
||||
timezone: "Europe/Helsinki",
|
||||
@@ -169,8 +163,7 @@ export async function seedAvailability({
|
||||
{
|
||||
userId: teams.allianceRogue.subUserId,
|
||||
timezone: "Europe/Helsinki",
|
||||
// Wednesday ends exactly at midnight, the shape the drag editor
|
||||
// produces when a bar is pulled to the 00:00 tick
|
||||
// Wednesday ends exactly at midnight, as the drag editor produces at the 00:00 tick
|
||||
weekly: [[], [["18:00", "22:00"]], [["18:00", "00:00"]], [], [], [], []],
|
||||
fillsNextWeek: true,
|
||||
},
|
||||
@@ -188,8 +181,7 @@ export async function seedAvailability({
|
||||
],
|
||||
fillsNextWeek: true,
|
||||
},
|
||||
// the last of the admin's friends reports nothing, so the friends page has
|
||||
// a row with no schedule to sort below the ones that have one
|
||||
// the last friend reports nothing, giving the friends page a schedule-less row to sort last
|
||||
...misc.adminFriendIds.slice(0, -1).map((userId, index) => ({
|
||||
userId,
|
||||
timezone: "Europe/Helsinki",
|
||||
|
||||
@@ -3,14 +3,10 @@ import type { SeededCalendarEvents } from "./calendar";
|
||||
import type { SeededTournaments } from "./tournaments";
|
||||
import type { SeededUsers } from "./users";
|
||||
|
||||
/** Calendar event results N-ZAP highlights, few enough that the highlights view of
|
||||
* his results page is a page shorter than the full one. */
|
||||
/** Few enough that the highlights view of N-ZAP's results page is a page shorter than the full one. */
|
||||
const NZAP_CALENDAR_HIGHLIGHT_COUNT = 6;
|
||||
|
||||
/**
|
||||
* Highlights some of N-ZAP's results, so that his profile has a highlighted results
|
||||
* widget and his results page opens on the highlights view.
|
||||
*/
|
||||
/** Gives N-ZAP a highlighted results widget and a results page opening on the highlights view. */
|
||||
export async function seedResultHighlights({
|
||||
users,
|
||||
calendarEvents,
|
||||
|
||||
@@ -29,7 +29,7 @@ const ADMIN_FRIEND_COUNT = 3;
|
||||
const STREAM_COUNT = 20;
|
||||
|
||||
export type SeededMisc = {
|
||||
/** The admin's friends, who are none of them their teammate. */
|
||||
/** None of them a teammate. */
|
||||
adminFriendIds: number[];
|
||||
};
|
||||
|
||||
|
||||
@@ -14,8 +14,7 @@ const SQUAD_COUNT = 8;
|
||||
const LOOKING_GROUP_COUNT = 10;
|
||||
const REPORTED_MAP_COUNT = 4;
|
||||
|
||||
/** N-ZAP's unconfirmed match, on an id worth remembering. Every other match is
|
||||
* created before it, so the squad matches make up the difference. */
|
||||
/** N-ZAP's unconfirmed match id; every other match is created before it, squad matches making up the difference. */
|
||||
const NZAP_MATCH_ID = 500;
|
||||
const SQUAD_MATCH_COUNT =
|
||||
NZAP_MATCH_ID - 1 - RECENT_MATCH_COUNT - OLDER_MATCH_COUNT;
|
||||
@@ -60,9 +59,7 @@ export async function seedSendouQ(
|
||||
return { recentMatchIds };
|
||||
}
|
||||
|
||||
/** A match N-ZAP's team has reported but the other has not confirmed, so it is the
|
||||
* other team's to report and N-ZAP's group is free to queue again. His side is
|
||||
* Alliance Rogue's lineup, so the match is one of a team against a pickup group. */
|
||||
/** Reported by N-ZAP's side (Alliance Rogue vs. a pickup), unconfirmed, so his group is free to queue again. */
|
||||
async function seedNzapReportedMatch(users: SeededUsers, teams: SeededTeams) {
|
||||
const opponentIds = users.crowdIds.slice(-88, -84);
|
||||
|
||||
@@ -81,9 +78,7 @@ async function seedNzapReportedMatch(users: SeededUsers, teams: SeededTeams) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One canceled match of each form the cancel reports take, so that the staff-only
|
||||
* views have every one of them to show: the two teams pointing at the same player,
|
||||
* at different ones, and a match staff canceled without either team's account of it. */
|
||||
/** Every cancel report form for the staff views: both teams naming the same player, different ones, and a staff cancel. */
|
||||
async function seedNzapCanceledMatches(users: SeededUsers, teams: SeededTeams) {
|
||||
const [nzapId, ...teammateIds] = allianceRogueLineup(teams);
|
||||
const opponentIds = users.crowdIds.slice(-104, -88);
|
||||
@@ -187,8 +182,7 @@ function allianceRogueLineup(teams: SeededTeams) {
|
||||
return allianceRogue.memberUserIds;
|
||||
}
|
||||
|
||||
/** Fixed team lineups playing together repeatedly, so their identifier skills reach
|
||||
* the match count the team leaderboard requires. */
|
||||
/** Fixed lineups playing repeatedly so their identifier skills reach the team leaderboard's match count. */
|
||||
async function seedSquadMatches(teams: SeededTeams) {
|
||||
const squads = teams.squads.slice(0, SQUAD_COUNT);
|
||||
|
||||
|
||||
@@ -83,8 +83,7 @@ export async function seedTeams(users: SeededUsers): Promise<SeededTeams> {
|
||||
}
|
||||
}
|
||||
|
||||
// showcase users double as members of a secondary team; disjoint chunks so
|
||||
// nobody exceeds the two-team limit
|
||||
// showcase users double as secondary team members; disjoint chunks keep everyone within the two-team limit
|
||||
for (let i = 0; i < SECONDARY_TEAM_COUNT; i++) {
|
||||
const memberUserIds = users.showcaseIds.slice(i * 4, i * 4 + 4);
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ import type { SeededTeams } from "./teams";
|
||||
import type { SeededTrophies } from "./trophies";
|
||||
import type { SeededUsers } from "./users";
|
||||
|
||||
/** Series the played-out tournaments of the past are named off. The four the seed
|
||||
* puts in a state worth opening are named off a series of their own. */
|
||||
/** Series the past tournaments are named off; the four in a state worth opening have series of their own. */
|
||||
const TOURNAMENT_NAME_STEMS = [
|
||||
{ name: "PICNIC", avatarFileName: "picnic.png" },
|
||||
{ name: "The Depths", avatarFileName: "the-depths.png" },
|
||||
@@ -32,8 +31,7 @@ const TOURNAMENT_NAME_STEMS = [
|
||||
const HISTORICAL_COUNT = 5;
|
||||
/** Showcase users seeded into every played tournament, so their results paginate. */
|
||||
const CORE_PLAYER_COUNT = 8;
|
||||
/** Share of a tournament's teams that register as one of the site's teams, the rest
|
||||
* being pickups put together for the tournament. */
|
||||
/** Share of teams registering as one of the site's teams, the rest being pickups. */
|
||||
const REGISTERED_TEAM_SHARE = 0.4;
|
||||
/** Solo players looking for a team in a tournament whose registration has closed. */
|
||||
const SUB_COUNT = 7;
|
||||
@@ -175,10 +173,10 @@ type Ctx = {
|
||||
rosters: ReturnType<typeof rosterBuilder>;
|
||||
};
|
||||
|
||||
/** #1 double elim, TO maps — reg open and a couple of days out, so it has both
|
||||
* registered teams (some of them still short of a full roster) and LFG teams.
|
||||
* The admin registers with Alliance Rogue on a roster whose availability mixes
|
||||
* every state the registration page's panel can show. */
|
||||
/**
|
||||
* #1 double elim, TO maps — reg open, a couple of days out: registered teams (some short of a full roster)
|
||||
* and LFG teams. The admin's Alliance Rogue roster mixes every availability state the panel can show.
|
||||
*/
|
||||
async function seedInTheZone({
|
||||
users,
|
||||
organizations,
|
||||
@@ -202,9 +200,8 @@ async function seedInTheZone({
|
||||
trophyId: trophies.ids[0],
|
||||
});
|
||||
|
||||
// availability panel states, in roster order: the admin and multiRange are
|
||||
// fully available, weekend is free only from an hour in, unavailable
|
||||
// submitted an empty week and the captain (N-ZAP) reports nothing at all
|
||||
// in roster order: admin and multiRange fully available, weekend free from an hour in, unavailable
|
||||
// submitted an empty week, the captain (N-ZAP) reports nothing
|
||||
const [, multiRangeId, , unavailableId, weekendId] =
|
||||
teams.allianceRogue.playerUserIds;
|
||||
const allianceRogueRoster: Roster = {
|
||||
@@ -247,9 +244,7 @@ async function seedInTheZone({
|
||||
};
|
||||
}
|
||||
|
||||
/** #2 double elim with an underground bracket, AUTO_SZ, ranked — bracket started,
|
||||
* not a single set reported yet. N-ZAP is seeded past the byes of the first round,
|
||||
* so he has a match of his own going on. */
|
||||
/** #2 double elim + underground, AUTO_SZ, ranked — started, nothing reported. N-ZAP is seeded past the byes so he has a match going. */
|
||||
async function seedPaddlingPool({ users, rosters }: Ctx) {
|
||||
const tournament = await TournamentFactory.create({
|
||||
name: nameFor("Paddling Pool"),
|
||||
@@ -306,8 +301,7 @@ async function seedLowInk({ users, rosters }: Ctx) {
|
||||
await TournamentFactory.playOut(tournament.id, 0);
|
||||
}
|
||||
|
||||
/** #4 round robin → SE, TO maps — everybody checked in, first bracket not started.
|
||||
* The one upcoming tournament N-ZAP is not registered in, so it is his saved one. */
|
||||
/** #4 round robin → SE, TO maps — everybody checked in, not started. N-ZAP isn't registered, so it is his saved one. */
|
||||
async function seedSwimOrSink({ users, rosters }: Ctx) {
|
||||
const tournament = await TournamentFactory.create({
|
||||
name: nameFor("Swim or Sink"),
|
||||
@@ -377,8 +371,7 @@ async function seedHistoricalTournaments({
|
||||
{ tier: ((i % 3) + 1) as TournamentTierNumber },
|
||||
);
|
||||
|
||||
// on the top seed of the first one, so that a win of his is finalized, and
|
||||
// further down another, so his result list is not all first places
|
||||
// top seed of the first (a finalized win), further down in another so his results aren't all firsts
|
||||
const nzapRosterIdx = i === 0 ? 0 : i === 2 ? 5 : null;
|
||||
|
||||
const teamRosters = rosters.take({
|
||||
@@ -408,8 +401,7 @@ async function seedHistoricalTournaments({
|
||||
return nzapTeamIds;
|
||||
}
|
||||
|
||||
/** Every edition of a series shares the one logo image of it, an image row not being
|
||||
* allowed the url of another. */
|
||||
/** Editions of a series share one logo image, an image row not being allowed the url of another. */
|
||||
async function seriesLogoImgId(
|
||||
imgIds: Map<string, number>,
|
||||
stem: (typeof TOURNAMENT_NAME_STEMS)[number],
|
||||
@@ -427,9 +419,7 @@ async function seriesLogoImgId(
|
||||
return image.id;
|
||||
}
|
||||
|
||||
/** Solo players looking to sub in a tournament whose registration has closed, the
|
||||
* admin among them so that the state of having posted is one of the two profiles'.
|
||||
* Drawn from the tail of the crowd, which no tournament roster reaches. */
|
||||
/** Subs for a closed-registration tournament, the admin among them. Drawn from the tail of the crowd no roster reaches. */
|
||||
async function seedSubs(tournamentId: number, users: SeededUsers) {
|
||||
const userIds = [
|
||||
users.adminId,
|
||||
@@ -451,8 +441,7 @@ async function seedTournamentExtras(tournamentId: number, users: SeededUsers) {
|
||||
await TournamentStreamerFactory.create({ tournamentId, twitchAccount });
|
||||
}
|
||||
|
||||
// N-ZAP used to be the demo LFG poster, but he registers with Alliance
|
||||
// Rogue now — a player cannot both be on a team and look for one
|
||||
// not N-ZAP: he registers with Alliance Rogue and a player can't both be on a team and look for one
|
||||
const lfgUserIds = users.showcaseIds.slice(90, 96);
|
||||
|
||||
const lfgTeamIds: number[] = [];
|
||||
@@ -507,12 +496,10 @@ function rosterBuilder(users: SeededUsers, teams: SeededTeams) {
|
||||
];
|
||||
|
||||
return {
|
||||
/** Rosters for one tournament: some of the site's teams registering as
|
||||
* themselves, core players spread over the rest, and the remaining seats drawn
|
||||
* without replacement within the tournament. A `pinned` user is added to a
|
||||
* roster of their own as its owner, and kept out of everybody else's. A
|
||||
* `preset` roster takes the first team slots exactly as given, its members
|
||||
* kept out of every other roster. */
|
||||
/**
|
||||
* Some site teams register as themselves, core players spread over the rest, remaining seats drawn without
|
||||
* replacement. A `pinned` user owns a roster of their own; a `preset` roster takes the first slots as given.
|
||||
*/
|
||||
take({
|
||||
teamCount,
|
||||
teamSize,
|
||||
@@ -551,8 +538,7 @@ function rosterBuilder(users: SeededUsers, teams: SeededTeams) {
|
||||
const shuffled = faker.helpers.shuffle(pool.filter(isFree));
|
||||
const freeCorePlayers = corePlayers.filter(isFree);
|
||||
|
||||
// the teams of the site take the first team slots a preset or a pin
|
||||
// does not want
|
||||
// site teams take the first slots a preset or a pin does not want
|
||||
const pinnedIdxs = new Set(pinned.map((pin) => pin.teamIdx));
|
||||
const registeringIdxs = Array.from({ length: teamCount }, (_, i) => i)
|
||||
.filter((i) => !pinnedIdxs.has(i) && i >= preset.length)
|
||||
|
||||
@@ -44,11 +44,7 @@ export async function seedTrophies({
|
||||
return { ids };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the trophies awarded off something other than a tournament win and hands
|
||||
* them to everybody eligible, as the nightly sync does. Runs last of the seed: who
|
||||
* is eligible follows from the patrons and X Rank placements seeded before it.
|
||||
*/
|
||||
/** Non-tournament trophies handed to everybody eligible like the nightly sync. Runs last: eligibility follows from patrons and X Rank placements. */
|
||||
export async function seedSpecialTrophies() {
|
||||
const models = TrophyFactory.MODELS;
|
||||
|
||||
|
||||
@@ -242,8 +242,7 @@ async function seedShowcaseUsers() {
|
||||
return { ids, artistIds, favoriteBadgeUserIds };
|
||||
}
|
||||
|
||||
/** Widget profile of the one seeded supporter: both slots filled, and every widget
|
||||
* whose content other modules seed onto N-ZAP. */
|
||||
/** The one seeded supporter's widgets: both slots filled, every widget other modules seed content for. */
|
||||
function nzapWidgets(): NonNullable<
|
||||
Parameters<typeof UserFactory.create>[1]
|
||||
>["widgets"] {
|
||||
|
||||
@@ -7,11 +7,7 @@ type InsertArgs = {
|
||||
type: ApiTokenType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates API tokens. `userId` is whose token it is — note that having one and being
|
||||
* allowed to use it are separate things, the permission coming from the user's roles.
|
||||
* The token itself is the repository's own.
|
||||
*/
|
||||
/** Having a token and being allowed to use it are separate things: the permission comes from the user's roles. */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({ type: "read" as const }),
|
||||
insert: ({ userId, type }: InsertArgs) =>
|
||||
|
||||
@@ -7,12 +7,7 @@ type InsertArgs = Parameters<typeof ArtRepository.insert>[0] & {
|
||||
authorId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates art.
|
||||
*
|
||||
* Validated by default, as art uploaded by a patron is, so that the listings
|
||||
* reading through the validated-images view can see it.
|
||||
*/
|
||||
/** Validated by default (as a patron's upload is) so listings reading the validated-images view see it. */
|
||||
export const { create, createMany } = defineFactory({
|
||||
defaults: ({ seq }) => ({
|
||||
url: `art-${seq}.png`,
|
||||
|
||||
@@ -7,11 +7,7 @@ type InsertArgs = Parameters<typeof AvailabilityRepository.upsertOwnWeek>[0] & {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the availability one user reported for one week. Slots and day notes
|
||||
* are absolute, so a range crossing midnight is given as one slot like any other.
|
||||
* A week with no slots is the "unavailable all week" a user submits.
|
||||
*/
|
||||
/** Slots are absolute (a range crossing midnight is one slot). No slots = "unavailable all week". */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({
|
||||
timezone: "Europe/Helsinki",
|
||||
|
||||
@@ -4,17 +4,13 @@ import { defineFactory } from "../core/defineFactory";
|
||||
import { faker } from "../core/faker";
|
||||
import * as SplatoonFaker from "../core/SplatoonFaker";
|
||||
|
||||
/**
|
||||
* Creates builds. `ownerId` is whose build it is. The ability and weapon rows every
|
||||
* build listing is read through are what the repository derives from `abilities` and
|
||||
* `weaponSplIds`; a multi-weapon build is `weaponSplIds` with more than one entry.
|
||||
*/
|
||||
const NO_GEAR = {
|
||||
headGearSplId: null,
|
||||
clothesGearSplId: null,
|
||||
shoesGearSplId: null,
|
||||
};
|
||||
|
||||
/** The repository derives the ability and weapon rows from `abilities` and `weaponSplIds` (several = multi-weapon build). */
|
||||
export const { create, createMany } = defineFactory({
|
||||
defaults: () => ({
|
||||
title: faker.lorem.words(3),
|
||||
|
||||
@@ -14,10 +14,7 @@ type InsertArgs = Omit<
|
||||
"isFullTournament" | "bracketProgression" | "mapPickingStyle"
|
||||
>;
|
||||
|
||||
/**
|
||||
* What every calendar event is defaulted to, tournaments included — a tournament is
|
||||
* a calendar event with one attached, see `TournamentFactory`.
|
||||
*/
|
||||
/** Defaults of every calendar event, tournaments (see `TournamentFactory`) included. */
|
||||
export const eventDefaults = () => ({
|
||||
name: faker.company.name(),
|
||||
description: faker.number.float(1) < 0.4 ? faker.lorem.paragraph() : null,
|
||||
|
||||
@@ -2,13 +2,7 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
|
||||
import { defineFactory } from "../core/defineFactory";
|
||||
import { faker } from "../core/faker";
|
||||
|
||||
/**
|
||||
* Creates reported results for a non-tournament calendar event, the way the event's
|
||||
* organizer reports them. `results` decides the placements and who played.
|
||||
*
|
||||
* Returns the result teams as they were created, so that a caller can pick one of
|
||||
* them (a result to highlight, say) by who played on it.
|
||||
*/
|
||||
/** Results of a non-tournament event as the organizer reports them. Returns the result teams as created. */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({
|
||||
participantCount: faker.number.int({ min: 10, max: 250 }),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import { defineFactory } from "../core/defineFactory";
|
||||
|
||||
/**
|
||||
* Creates pending friend requests: `senderId` has asked `receiverId` to be friends.
|
||||
* A request that was accepted is a friendship instead, see `FriendshipFactory`.
|
||||
*/
|
||||
/** Pending requests from `senderId` to `receiverId`. An accepted one is a friendship, see `FriendshipFactory`. */
|
||||
export const { create, createMany } = defineFactory({
|
||||
defaults: () => ({}),
|
||||
insert: FriendRepository.insertFriendRequest,
|
||||
|
||||
@@ -6,11 +6,7 @@ type InsertArgs = Omit<
|
||||
"friendRequestId"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Creates friendships between `userOneId` and `userTwoId`, in the order production
|
||||
* makes them: the request one sent the other is created first and consumed by the
|
||||
* friendship. Which of the two is stored as the first user is the repository's own.
|
||||
*/
|
||||
/** Like production: a request is created first and consumed by the friendship. Stored order is the repository's own. */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({}),
|
||||
insert: async ({ userOneId, userTwoId }: InsertArgs) => {
|
||||
|
||||
@@ -9,12 +9,7 @@ type InsertArgs = Parameters<
|
||||
userId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the votes a SendouQ group casts on carrying on with the same teammates
|
||||
* after a match. A vote against clears the group's votes in favour, since those
|
||||
* were for carrying on at a size the group no longer has — the repository's own
|
||||
* doing, which is why the votes go through it in the order they were cast.
|
||||
*/
|
||||
/** Votes on carrying on after a match. A vote against clears the votes in favour, so they go through the repository in cast order. */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({ isContinuing: true }),
|
||||
insert: ({ userId, ...args }: InsertArgs) =>
|
||||
|
||||
@@ -10,14 +10,8 @@ type Options = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates user submitted images, unvalidated as the repository function makes them.
|
||||
* An image on its own is an orphan — the queries counting images for approval only
|
||||
* see it once something (a calendar event avatar, a team logo) points at it.
|
||||
*
|
||||
* The url is one of the numbered logos seeded to the local image storage, so that an
|
||||
* image the caller does not name renders in dev instead of 404ing.
|
||||
*
|
||||
* Art brings its own image, see `ArtFactory`.
|
||||
* Unvalidated by default. An image alone is an orphan: approval queries only see it once something points
|
||||
* at it. Default url is a numbered logo seeded to local storage so it renders in dev. Art: see `ArtFactory`.
|
||||
*/
|
||||
export const { create } = defineFactory({
|
||||
defaults: ({ seq }) => ({
|
||||
|
||||
@@ -8,11 +8,8 @@ type Stream = Omit<Tables["LiveStream"], "id">;
|
||||
type StreamOverrides = Partial<Stream> & Pick<Stream, "userId">;
|
||||
|
||||
/**
|
||||
* Replaces the live streams with one per entry, the same write the twitch poller
|
||||
* does. A later call replaces the earlier's streams, so seed them all at once.
|
||||
*
|
||||
* A stream credited to a user also links its Twitch account to them, as the poller
|
||||
* only credits a stream to the user who has that account on their profile.
|
||||
* Same write as the twitch poller, so seed all streams at once. A stream credited to a user also links the
|
||||
* Twitch account to them, as the poller only credits users with the account on their profile.
|
||||
*/
|
||||
export async function replaceAll(streams: StreamOverrides[]) {
|
||||
const filledStreams = streams.map(fillStream);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as LogInLinkRepository from "~/features/auth/LogInLinkRepository.server";
|
||||
import { defineFactory } from "../core/defineFactory";
|
||||
|
||||
/**
|
||||
* Creates the single use links the log in with a code flow hands out. `userId` is
|
||||
* who the link logs in; its code and expiry are the repository's own.
|
||||
*/
|
||||
/** Single use links of the log in with a code flow; `userId` is who the link logs in. */
|
||||
export const { create } = defineFactory({
|
||||
insert: ({ userId }: { userId: number }) =>
|
||||
LogInLinkRepository.insert(userId),
|
||||
|
||||
@@ -7,10 +7,7 @@ import invariant from "~/utils/invariant";
|
||||
import { defineFactory } from "../core/defineFactory";
|
||||
import { faker } from "../core/faker";
|
||||
|
||||
/**
|
||||
* Creates plus server suggestions, `authorId` suggesting `suggestedId` for `tier`.
|
||||
* Defaults to the upcoming voting's month, i.e. a suggestion that is currently open.
|
||||
*/
|
||||
/** `authorId` suggests `suggestedId` for `tier`. Defaults to the upcoming voting's month, i.e. currently open. */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({
|
||||
...upcomingVotingMonthYear(),
|
||||
|
||||
@@ -12,11 +12,7 @@ type Vote = UpsertManyPlusVotesArgs[number];
|
||||
|
||||
const VOTING_ENDED_AGO = { minutes: 5 };
|
||||
|
||||
/**
|
||||
* Creates plus server votes, `authorId` being who cast the vote and `votedId` who it
|
||||
* was cast on. Defaults to a vote of the latest completed voting, which is one that
|
||||
* already counts towards the plus tiers.
|
||||
*/
|
||||
/** `authorId` cast the vote on `votedId`. Defaults to the latest completed voting, which already counts towards tiers. */
|
||||
export const { create, createMany } = defineFactory({
|
||||
defaults: () => ({
|
||||
...lastCompletedVoting(new Date()),
|
||||
@@ -25,9 +21,7 @@ export const { create, createMany } = defineFactory({
|
||||
becomesValidAt: dateToDatabaseTimestamp(sub(new Date(), VOTING_ENDED_AGO)),
|
||||
}),
|
||||
insert: async (vote: Vote) => {
|
||||
// `upsertMany` replaces every vote its author cast that month, being one
|
||||
// submission of the voting form, so the votes cast before this one are read
|
||||
// back and sent along
|
||||
// `upsertMany` replaces every vote the author cast that month, so earlier ones are read back and sent along
|
||||
const alreadyCast = await db
|
||||
.selectFrom("PlusVote")
|
||||
.selectAll()
|
||||
|
||||
@@ -9,11 +9,7 @@ type ReplaceAllArgs = {
|
||||
resultTournamentTeamIds: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the results a user has highlighted on their profile, the same write the
|
||||
* highlight picking page does. A later call replaces the earlier's highlights, so
|
||||
* seed them all at once.
|
||||
*/
|
||||
/** Same write as the highlight picking page, so seed all highlights at once. */
|
||||
export function replaceAll({ userId, ...args }: ReplaceAllArgs) {
|
||||
return actAs(userId, () => UserRepository.updateOwnResultHighlights(args));
|
||||
}
|
||||
|
||||
@@ -18,11 +18,7 @@ type Options = {
|
||||
likedByGroupIds?: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates SendouQ groups. The first of `memberUserIds` is the creator, whose
|
||||
* membership the repository creates with the group; the rest join it the way they do
|
||||
* in production. Invite and chat codes are the repository's own.
|
||||
*/
|
||||
/** First of `memberUserIds` is the creator, the rest join like in production. */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({
|
||||
status: "ACTIVE" as const,
|
||||
@@ -58,8 +54,7 @@ export const { create } = defineFactory({
|
||||
}
|
||||
|
||||
if (isMatchmade) {
|
||||
// written directly because the only production write of the column is
|
||||
// `morphGroups`, which needs two separate groups to merge into one
|
||||
// written directly: the only production write is `morphGroups`, which needs two groups to merge
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ matchmade: 1 })
|
||||
|
||||
@@ -33,17 +33,13 @@ type CancelReport = {
|
||||
};
|
||||
|
||||
type Options = {
|
||||
/** Play the match out, alpha winning every map, up to both teams having agreed
|
||||
* on the score. Leaves both groups inactive, as a real concluded match does. */
|
||||
/** Alpha wins every map and both teams agree on the score. Both groups end up inactive. */
|
||||
isConcluded?: boolean;
|
||||
/** Cancel the match the way the two teams do: alpha's owner requests the
|
||||
* cancellation and bravo's owner accepts it, each giving their own account. */
|
||||
/** Alpha's owner requests the cancellation and bravo's accepts, each giving their own account. */
|
||||
cancel?: { requested: CancelReport; accepted: CancelReport };
|
||||
/** Cancel the match the way staff does, leaving neither team an account of it. */
|
||||
canceledByStaffUserId?: number;
|
||||
/** Play the match out, alpha winning every map, and report the score as alpha —
|
||||
* leaving bravo still to confirm it. Alpha's group goes inactive, as reporting
|
||||
* makes it, so its members are free to queue again. */
|
||||
/** Alpha wins every map and reports, bravo still to confirm. Alpha's group goes inactive. */
|
||||
isReported?: boolean;
|
||||
/** When the match was made, for one that should look older than now. */
|
||||
createdAt?: Date;
|
||||
@@ -51,11 +47,7 @@ type Options = {
|
||||
confirmedAt?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates SendouQ matches together with the two groups playing them: a match is
|
||||
* only ever made out of two full groups, the way the matchmaking UI makes one, so
|
||||
* the groups are not the caller's to bring. Both are returned with the match.
|
||||
*/
|
||||
/** Creates the two full groups with the match, like the matchmaking UI does. Both are returned with the match. */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({
|
||||
mapList: SplatoonFaker.mapList(SENDOUQ_BEST_OF).map((map) => ({
|
||||
@@ -139,8 +131,7 @@ function memberTiers(userIds: number[]) {
|
||||
return userIds.map((userId) => ({ userId, tier: SEEDED_TIER }));
|
||||
}
|
||||
|
||||
/** Concluding stamps the skill rows *now*; move them to when the match was played
|
||||
* so the season progression chart spreads over days. */
|
||||
/** Concluding stamps skill rows *now*; backdating spreads the season progression chart over days. */
|
||||
async function backdateSkills(matchId: number, createdAt: Date) {
|
||||
const skills = await db
|
||||
.selectFrom("Skill")
|
||||
|
||||
@@ -8,11 +8,7 @@ type Options = {
|
||||
confirmedByUserIds?: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the ready check two matched up groups go through before their match is
|
||||
* created. Both groups have to be active, since a ready check is what takes them
|
||||
* out of the looking pool.
|
||||
*/
|
||||
/** Both groups have to be active, since a ready check is what takes them out of the looking pool. */
|
||||
export const { create } = defineFactory({
|
||||
insert: (args: InsertArgs) => SQGroupRepository.insertReadyCheck(args),
|
||||
applyOptions: async (readyCheck, { confirmedByUserIds }: Options) => {
|
||||
|
||||
@@ -7,13 +7,7 @@ type InsertArgs = Parameters<typeof ReportedWeaponRepository.upsertOwn>[0] & {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the weapons a SendouQ match's players report having used. `userId` is the
|
||||
* player whose weapon it was, on whose behalf it is reported.
|
||||
*
|
||||
* `mapIndex` is not defaulted: it is what identifies the row, so a second weapon
|
||||
* without one would replace the first rather than add to it.
|
||||
*/
|
||||
/** `userId` is whose weapon it was. `mapIndex` identifies the row so it is not defaulted, or a second weapon would replace the first. */
|
||||
export const { createMany } = defineFactory({
|
||||
defaults: () => ({
|
||||
weaponSplId: SplatoonFaker.mainWeapon(),
|
||||
|
||||
@@ -8,10 +8,7 @@ type InsertArgs = {
|
||||
tournamentId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a tournament for a user, the way the star on its page does. Only a
|
||||
* tournament can be saved, a calendar event without one having nothing to save.
|
||||
*/
|
||||
/** Like the star on a tournament page. Only tournaments can be saved. */
|
||||
export const { create } = defineFactory({
|
||||
insert: async ({ userId, tournamentId }: InsertArgs) => {
|
||||
await actAs(userId, () =>
|
||||
|
||||
@@ -8,8 +8,7 @@ type InsertRequestArgs = Parameters<
|
||||
|
||||
type Request = Pick<InsertRequestArgs, "users"> &
|
||||
Partial<Pick<InsertRequestArgs, "startsAt">> & {
|
||||
/** Books the scrim, the way accepting a request does in production. Only one
|
||||
* request of a post may be accepted. */
|
||||
/** Books the scrim. Only one request of a post may be accepted. */
|
||||
isAccepted?: boolean;
|
||||
};
|
||||
|
||||
@@ -18,10 +17,7 @@ type Options = {
|
||||
requests?: Array<Request>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates scrim posts. `users` is the side offering the scrim, one of them its owner.
|
||||
* The requests made to the post, and which of them booked it, are `options`.
|
||||
*/
|
||||
/** `users` is the side offering the scrim, one of them its owner. Requests to the post are `options`. */
|
||||
export const { create } = defineFactory({
|
||||
defaults: () => ({
|
||||
startsAt: databaseTimestampNow(),
|
||||
|
||||
@@ -8,17 +8,12 @@ type Options = {
|
||||
matchesCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a user's starting skill of a season. `mu` is the rating the ordinal every
|
||||
* ranking reads is derived from, so a user given a higher `mu` than another ranks
|
||||
* above them.
|
||||
*/
|
||||
/** Starting skill of a season. Rankings read the ordinal derived from `mu`, so a higher `mu` ranks higher. */
|
||||
export const { create, createMany } = defineFactory({
|
||||
defaults: () => ({ season: 1, ...rating() }),
|
||||
insert: SkillRepository.addInitialSkill,
|
||||
applyOptions: async (skill, { matchesCount }: Options) => {
|
||||
// written directly because production only raises `matchesCount` while reporting
|
||||
// a SendouQ match or finalizing a tournament, and a starting skill has neither
|
||||
// written directly: production only raises `matchesCount` when reporting a match or finalizing a tournament
|
||||
await db
|
||||
.updateTable("Skill")
|
||||
.set({ matchesCount })
|
||||
|
||||
@@ -10,10 +10,7 @@ const ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const;
|
||||
const ROTATIONS_PER_TYPE = 12;
|
||||
const TWO_HOURS = 2 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Replaces the Splatoon rotations with a schedule starting from the current
|
||||
* two-hour slot, the same write the rotation sync routine does.
|
||||
*/
|
||||
/** Schedule starting from the current two-hour slot, same write as the rotation sync routine. */
|
||||
export function replaceAll() {
|
||||
const nowUnix = Math.floor(Date.now() / 1000);
|
||||
const currentSlotStartsAt = nowUnix - (nowUnix % TWO_HOURS);
|
||||
|
||||
@@ -25,12 +25,7 @@ type Options = {
|
||||
roles?: Record<number, MemberRole>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates teams. The first of `memberUserIds` is the owner, whose membership the
|
||||
* repository creates with the team; the rest join it the way they do in production,
|
||||
* within the team count a non-patron is allowed. Custom url and invite code are the
|
||||
* repository's own, the custom url following from the name.
|
||||
*/
|
||||
/** First of `memberUserIds` is the owner, the rest join like in production (within the non-patron team limit). */
|
||||
export const { create } = defineFactory({
|
||||
defaults: ({ seq }) => ({
|
||||
name: `Team ${seq}`,
|
||||
@@ -88,8 +83,7 @@ export const { create } = defineFactory({
|
||||
{ isValidated: true },
|
||||
);
|
||||
|
||||
// the team edit page saves the whole profile at once; everything besides the
|
||||
// name is still empty on a team the repository has only just inserted
|
||||
// the team edit page saves the whole profile at once; the rest is still empty on a fresh insert
|
||||
await TeamRepository.update({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user