Simplify comments

This commit is contained in:
Kalle
2026-09-02 21:02:01 +03:00
parent fe797e1891
commit 6a307ce59e
770 changed files with 4302 additions and 11417 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => ({

View File

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

View File

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

View File

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

View File

@@ -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,
}: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = [
"スプラちゃん",
"いかタコどん",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"] {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -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 }) => ({

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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(),

View File

@@ -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, () =>

View File

@@ -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(),

View File

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

View File

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

View File

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