diff --git a/app/components/Badge.tsx b/app/components/Badge.tsx index 810a0f4cb..220fba4d5 100644 --- a/app/components/Badge.tsx +++ b/app/components/Badge.tsx @@ -1,17 +1,14 @@ import { badgeUrl } from "~/utils/urls"; import { Image } from "./Image"; -export function Badge({ - badge, - onClick, - isAnimated, - size, -}: { +export interface BadgeProps { badge: { displayName: string; hue?: number | null; code: string }; onClick?: () => void; isAnimated: boolean; size: number; -}) { +} + +export function Badge({ badge, onClick, isAnimated, size }: BadgeProps) { const commonProps = { title: badge.displayName, onClick, diff --git a/app/components/CopyToClipboardPopover.tsx b/app/components/CopyToClipboardPopover.tsx new file mode 100644 index 000000000..599fa36e9 --- /dev/null +++ b/app/components/CopyToClipboardPopover.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useCopyToClipboard } from "react-use"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { CheckmarkIcon } from "~/components/icons/Checkmark"; +import { ClipboardIcon } from "~/components/icons/Clipboard"; + +interface CopyToClipboardPopoverProps { + url: string; + trigger: React.ReactNode; +} + +export function CopyToClipboardPopover({ + trigger, + url, +}: CopyToClipboardPopoverProps) { + const { t } = useTranslation(["common"]); + const [state, copyToClipboard] = useCopyToClipboard(); + const [copySuccess, setCopySuccess] = React.useState(false); + + React.useEffect(() => { + if (!state.value) return; + + setCopySuccess(true); + const timeout = setTimeout(() => setCopySuccess(false), 2000); + + return () => clearTimeout(timeout); + }, [state]); + + return ( + +
+ + copyToClipboard(url)} + icon={copySuccess ? : } + > + {t("common:actions.copyToClipboard")} + +
+
+ ); +} diff --git a/app/components/Image.tsx b/app/components/Image.tsx index 85867e46f..45b96ebd2 100644 --- a/app/components/Image.tsx +++ b/app/components/Image.tsx @@ -1,7 +1,8 @@ import clsx from "clsx"; import { useTranslation } from "react-i18next"; import type { TierName } from "~/features/mmr/mmr-constants"; -import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists"; +import type { MainWeaponId, StageId } from "~/modules/in-game-lists"; +import type { ModeShortWithSpecial } from "~/modules/in-game-lists/types"; import { TIER_PLUS_URL, mainWeaponImageUrl, @@ -104,7 +105,7 @@ export function WeaponImage({ } type ModeImageProps = { - mode: ModeShort; + mode: ModeShortWithSpecial; } & Omit; export function ModeImage({ mode, testId, title, ...rest }: ModeImageProps) { diff --git a/app/components/MapPoolSelector.tsx b/app/components/MapPoolSelector.tsx index f5782f39d..7eb544f5d 100644 --- a/app/components/MapPoolSelector.tsx +++ b/app/components/MapPoolSelector.tsx @@ -12,7 +12,7 @@ import { type StageId, modesShort, } from "~/modules/in-game-lists"; -import { modes, stageIds } from "~/modules/in-game-lists"; +import { stageIds } from "~/modules/in-game-lists"; import { split, startsWith } from "~/utils/strings"; import { assertType } from "~/utils/types"; import { modeImageUrl, stageImageUrl } from "~/utils/urls"; @@ -263,25 +263,24 @@ export function MapPoolStages({ {t(`game-misc:STAGE_${stageId}`)}
- {modes + {modesShort .filter( - (mode) => - !modesToInclude || modesToInclude.includes(mode.short), + (mode) => !modesToInclude || modesToInclude.includes(mode), ) .map((mode) => { - const selected = mapPool.has({ stageId, mode: mode.short }); + const selected = mapPool.has({ stageId, mode }); if (isPresentational && !selected) return null; if (isPresentational && selected) { return ( {t(`game-misc:MODE_LONG_${mode.short}`)} @@ -290,24 +289,21 @@ export function MapPoolStages({ const preselected = preselectedMapPool?.has({ stageId, - mode: mode.short, + mode, }); return ( + + + + + {(date) => { + return ; + }} + + + ); +} diff --git a/app/components/elements/DatePicker.tsx b/app/components/elements/DatePicker.tsx index 2d1def797..050103795 100644 --- a/app/components/elements/DatePicker.tsx +++ b/app/components/elements/DatePicker.tsx @@ -1,26 +1,21 @@ import clsx from "clsx"; import { Button, - Calendar, - CalendarCell, - CalendarGrid, DateInput, type DatePickerProps, DateSegment, type DateValue, Dialog, Group, - Heading, Popover, DatePicker as ReactAriaDatePicker, } from "react-aria-components"; import { SendouBottomTexts } from "~/components/elements/BottomTexts"; +import { SendouCalendar } from "~/components/elements/Calendar"; import { type FormFieldSize, formFieldSizeToClassName, } from "../form/form-utils"; -import { ArrowLeftIcon } from "../icons/ArrowLeft"; -import { ArrowRightIcon } from "../icons/ArrowRight"; import { CalendarIcon } from "../icons/Calendar"; import { SendouLabel } from "./Label"; @@ -54,24 +49,7 @@ export function SendouDatePicker({ - -
- - - -
- - {(date) => { - return ( - - ); - }} - -
+
diff --git a/app/components/elements/Dialog.module.css b/app/components/elements/Dialog.module.css index d95c9228e..505b86e62 100644 --- a/app/components/elements/Dialog.module.css +++ b/app/components/elements/Dialog.module.css @@ -6,7 +6,7 @@ background-color: rgba(0, 0, 0, 0.25); display: flex; min-height: 100%; - align-items: center; + align-items: flex-start; justify-content: center; padding: 1rem; text-align: center; @@ -43,6 +43,7 @@ vertical-align: middle; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + margin: auto; } .fullScreenModal { diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index e1241521d..ce4697d4c 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -6,6 +6,19 @@ import { type PopoverProps, } from "react-aria-components"; +/** + * 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 + * Click me} + * > + * Popover content goes here! + * + * ``` + */ export function SendouPopover({ children, trigger, diff --git a/app/components/form/DateFormField.tsx b/app/components/form/DateFormField.tsx index 8f2b795cd..bc32963a1 100644 --- a/app/components/form/DateFormField.tsx +++ b/app/components/form/DateFormField.tsx @@ -1,11 +1,10 @@ -import { parseDate } from "@internationalized/date"; import { Controller, type FieldPath, type FieldValues, useFormContext, } from "react-hook-form"; -import { dateToYYYYMMDD } from "../../utils/dates"; +import { dayMonthYearToDateValue } from "../../utils/dates"; import type { DayMonthYear } from "../../utils/zod"; import { SendouDatePicker } from "../elements/DatePicker"; import type { FormFieldSize } from "./form-utils"; @@ -38,18 +37,7 @@ export function DateFormField({ if (!originalValue) return null; - const isoString = dateToYYYYMMDD( - new Date( - Date.UTC( - originalValue.year, - originalValue.month, - originalValue.day, - 12, - ), - ), - ); - - return parseDate(isoString); + return dayMonthYearToDateValue(originalValue); }; return ( diff --git a/app/components/form/TextFormField.tsx b/app/components/form/InputFormField.tsx similarity index 90% rename from app/components/form/TextFormField.tsx rename to app/components/form/InputFormField.tsx index f97633c9e..9353f650e 100644 --- a/app/components/form/TextFormField.tsx +++ b/app/components/form/InputFormField.tsx @@ -9,13 +9,14 @@ import { FormMessage } from "~/components/FormMessage"; import { Label } from "~/components/Label"; import { type FormFieldSize, formFieldSizeToClassName } from "./form-utils"; -export function TextFormField({ +export function InputFormField({ label, name, bottomText, placeholder, required, size = "small", + type, }: { label: string; name: FieldPath; @@ -23,6 +24,7 @@ export function TextFormField({ placeholder?: string; required?: boolean; size?: FormFieldSize; + type?: React.HTMLInputTypeAttribute; }) { const methods = useFormContext(); const id = React.useId(); @@ -37,6 +39,7 @@ export function TextFormField({ diff --git a/app/components/form/InputGroupFormField.tsx b/app/components/form/InputGroupFormField.tsx new file mode 100644 index 000000000..8ac14b990 --- /dev/null +++ b/app/components/form/InputGroupFormField.tsx @@ -0,0 +1,118 @@ +import * as React from "react"; +import { + Controller, + type FieldPath, + type FieldValues, + useFormContext, +} from "react-hook-form"; +import { FormMessage } from "~/components/FormMessage"; + +interface InputGroupFormFieldProps { + label: string; + name: FieldPath; + bottomText?: string; + type: "checkbox" | "radio"; + values: Array<{ + label: string; + value: string; + }>; +} + +export function InputGroupFormField({ + label, + name, + bottomText, + values, + type, +}: InputGroupFormFieldProps) { + const methods = useFormContext(); + + return ( + { + const handleCheckboxChange = + (name: string) => (newChecked: boolean) => { + const newValue = newChecked + ? [...(value || []), name] + : value?.filter((v: string) => v !== name); + + onChange(newValue); + }; + + const handleRadioChange = (name: string) => () => { + onChange(name); + }; + + return ( +
+
+ {label} + + {values.map((checkbox) => { + const isChecked = value?.includes(checkbox.value); + + return ( + + {checkbox.label} + + ); + })} +
+ {error && ( + {error.message as string} + )} + {bottomText && !error ? ( + {bottomText} + ) : null} +
+ ); + }} + /> + ); +} + +function GroupInput({ + children, + name, + checked, + onChange, + type, +}: { + children: React.ReactNode; + name: string; + checked: boolean; + onChange: (newChecked: boolean) => void; + type: "checkbox" | "radio"; +}) { + const id = React.useId(); + + return ( +
+ onChange(e.target.checked)} + /> + +
+ ); +} diff --git a/app/components/form/MyForm.tsx b/app/components/form/SendouForm.tsx similarity index 97% rename from app/components/form/MyForm.tsx rename to app/components/form/SendouForm.tsx index 14637ddf7..dbf405bc7 100644 --- a/app/components/form/MyForm.tsx +++ b/app/components/form/SendouForm.tsx @@ -9,7 +9,7 @@ import type { ActionError } from "~/utils/remix.server"; import { LinkButton } from "../Button"; import { SubmitButton } from "../SubmitButton"; -export function MyForm({ +export function SendouForm({ schema, defaultValues, heading, diff --git a/app/components/form/TextArrayFormField.tsx b/app/components/form/TextArrayFormField.tsx index d6bdcc61f..44cb1a545 100644 --- a/app/components/form/TextArrayFormField.tsx +++ b/app/components/form/TextArrayFormField.tsx @@ -1,20 +1,25 @@ -import { useFieldArray, useFormContext } from "react-hook-form"; -import type { z } from "zod"; +import { + type FieldPath, + type FieldValues, + useFieldArray, + useFormContext, +} from "react-hook-form"; import { FormMessage } from "~/components/FormMessage"; import { Label } from "~/components/Label"; import { AddFieldButton } from "./AddFieldButton"; import { RemoveFieldButton } from "./RemoveFieldButton"; -export function TextArrayFormField({ +export function TextArrayFormField({ label, name, - defaultFieldValue, bottomText, + /** If "plain", value in the text array is a plain string. If "object" then an object containing the text under "value" key */ + format = "plain", }: { label: string; - name: keyof z.infer & string; - defaultFieldValue: string; + name: FieldPath; bottomText?: string; + format?: "plain" | "object"; }) { const { register, @@ -38,15 +43,19 @@ export function TextArrayFormField({ return (
- - {fields.length > 1 ? ( - { - remove(index); - clearErrors(`${name}.root`); - }} - /> - ) : null} + + { + remove(index); + clearErrors(`${name}.root`); + }} + />
{error && ( @@ -58,7 +67,7 @@ export function TextArrayFormField({ })} append({ value: defaultFieldValue })} + onClick={() => append(format === "plain" ? "" : { value: "" })} /> {rootError && ( {rootError.message as string} diff --git a/app/components/form/ToggleFormField.tsx b/app/components/form/ToggleFormField.tsx index 340fcd592..e3cfe09ae 100644 --- a/app/components/form/ToggleFormField.tsx +++ b/app/components/form/ToggleFormField.tsx @@ -27,7 +27,11 @@ export function ToggleFormField({ control={methods.control} name={name} render={({ field: { value, onChange } }) => ( - + )} /> {error && ( diff --git a/app/components/icons/FilterFilled.tsx b/app/components/icons/FilterFilled.tsx new file mode 100644 index 000000000..087d34f4a --- /dev/null +++ b/app/components/icons/FilterFilled.tsx @@ -0,0 +1,17 @@ +export function FilterFilledIcon({ className }: { className?: string }) { + return ( + + Filter Icon + + + ); +} diff --git a/app/components/icons/Trophy.tsx b/app/components/icons/Trophy.tsx new file mode 100644 index 000000000..1734b6f86 --- /dev/null +++ b/app/components/icons/Trophy.tsx @@ -0,0 +1,20 @@ +export function TrophyIcon({ + className, + title, +}: { className?: string; title?: string }) { + return ( + + {title ?? "Trophy Icon"} + + + ); +} diff --git a/app/components/layout/NotificationPopover.tsx b/app/components/layout/NotificationPopover.tsx index 37cb56fd9..b94168b24 100644 --- a/app/components/layout/NotificationPopover.tsx +++ b/app/components/layout/NotificationPopover.tsx @@ -79,6 +79,7 @@ function NotificationContent({ const { t } = useTranslation(["common"]); const { revalidate, state } = useRevalidator(); + // TODO: for some reason this makes "adds a badge owner sending a notification" E2E test flaky, figure out why and fix useMarkNotificationsAsSeen(unseenIds); return ( @@ -131,6 +132,7 @@ function NotificationsFooter() { size="tiny" to={NOTIFICATIONS_URL} className="mt-1-5" + testId="notifications-see-all-button" > {t("common:notifications.seeAll")} diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 30402b1b6..bba17da0e 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -8,7 +8,7 @@ import type { SeedVariation } from "~/features/api-private/routes/seed"; import * as AssociationRepository from "~/features/associations/AssociationRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import { persistedTags } from "~/features/calendar/calendar-constants"; +import { tags } from "~/features/calendar/calendar-constants"; import * as LFGRepository from "~/features/lfg/LFGRepository.server"; import { TIMEZONES } from "~/features/lfg/lfg-constants"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; @@ -718,7 +718,7 @@ function calendarEvents() { const userIds = userIdsInRandomOrder(); for (let id = 1; id <= AMOUNT_OF_CALENDAR_EVENTS; id++) { - const shuffledTags = faker.helpers.shuffle(Object.keys(persistedTags)); + const shuffledTags = faker.helpers.shuffle(Object.keys(tags)); sql .prepare( diff --git a/app/db/tables.ts b/app/db/tables.ts index 1edee9bb1..59d39e3cf 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -8,10 +8,8 @@ import type { Updateable, } from "kysely"; import type { AssociationVisibility } from "~/features/associations/associations-types"; -import type { - persistedTags, - tags, -} from "~/features/calendar/calendar-constants"; +import type { tags } from "~/features/calendar/calendar-constants"; +import type { CalendarFilters } from "~/features/calendar/calendar-types"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import type { Notification as NotificationValue } from "~/features/notifications/notifications-types"; import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants"; @@ -130,7 +128,6 @@ export type CalendarEventAvatarMetadata = { textColor: string; }; -export type PersistedCalendarEventTag = keyof typeof persistedTags; export type CalendarEventTag = keyof typeof tags; export interface CalendarEvent { @@ -802,6 +799,7 @@ export type BuildSort = (typeof BUILD_SORT_IDENTIFIERS)[number]; export interface UserPreferences { disableBuildAbilitySorting?: boolean; disallowScrimPickupsFromUntrusted?: boolean; + defaultCalendarFilters?: CalendarFilters; } export interface User { diff --git a/app/features/api-public/routes/calendar.$year.$week.ts b/app/features/api-public/routes/calendar.$year.$week.ts index ee1509f0d..32d1ee5c5 100644 --- a/app/features/api-public/routes/calendar.$year.$week.ts +++ b/app/features/api-public/routes/calendar.$year.$week.ts @@ -1,8 +1,12 @@ import { type LoaderFunctionArgs, json } from "@remix-run/node"; import { cors } from "remix-utils/cors"; import { z } from "zod"; -import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import { databaseTimestampToDate, weekNumberToDate } from "~/utils/dates"; +import { db } from "~/db/sql"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, + weekNumberToDate, +} from "~/utils/dates"; import { parseParams } from "~/utils/remix.server"; import { handleOptionsRequest, @@ -44,10 +48,30 @@ function fetchEventsOfWeek(args: { week: number; year: number }) { const endTime = new Date(startTime); endTime.setDate(endTime.getDate() + 7); - return CalendarRepository.findAllBetweenTwoTimestamps({ - startTime, - endTime, - tagsToFilterBy: [], - onlyTournaments: false, - }); + return db + .selectFrom("CalendarEvent") + .innerJoin( + "CalendarEventDate", + "CalendarEvent.id", + "CalendarEventDate.eventId", + ) + .leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id") + .select([ + "Tournament.id as tournamentId", + "CalendarEvent.name", + "CalendarEventDate.startTime", + ]) + .where( + "CalendarEventDate.startTime", + ">=", + dateToDatabaseTimestamp(startTime), + ) + .where( + "CalendarEventDate.startTime", + "<=", + dateToDatabaseTimestamp(endTime), + ) + .where("CalendarEvent.hidden", "=", 0) + .orderBy("CalendarEventDate.startTime", "asc") + .execute(); } diff --git a/app/features/associations/routes/associations.new.tsx b/app/features/associations/routes/associations.new.tsx index 4d82d4b20..ecfebf116 100644 --- a/app/features/associations/routes/associations.new.tsx +++ b/app/features/associations/routes/associations.new.tsx @@ -1,8 +1,8 @@ import { useTranslation } from "react-i18next"; import type { z } from "zod"; import { SendouDialog } from "~/components/elements/Dialog"; -import { MyForm } from "~/components/form/MyForm"; -import { TextFormField } from "~/components/form/TextFormField"; +import { InputFormField } from "~/components/form/InputFormField"; +import { SendouForm } from "~/components/form/SendouForm"; import { createNewAssociationSchema } from "~/features/associations/associations-schemas"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { associationsPage } from "~/utils/urls"; @@ -24,17 +24,17 @@ export default function AssociationsNewPage() { heading={t("scrims:associations.forms.title")} onCloseTo={associationsPage()} > - - + label={t("scrims:associations.forms.name.title")} name="name" /> - + ); } diff --git a/app/features/badges/components/BadgeDisplay.tsx b/app/features/badges/components/BadgeDisplay.tsx index 32412c35f..fce258bff 100644 --- a/app/features/badges/components/BadgeDisplay.tsx +++ b/app/features/badges/components/BadgeDisplay.tsx @@ -16,12 +16,16 @@ export interface BadgeDisplayProps { badges: Array & { count?: number }>; onChange?: (badgeIds: number[]) => void; children?: React.ReactNode; + showText?: boolean; + className?: string; } export function BadgeDisplay({ badges: _badges, onChange, children, + showText = true, + className, }: BadgeDisplayProps) { const { t } = useTranslation("badges"); const [badges, setBadges] = React.useState(_badges); @@ -58,13 +62,13 @@ export function BadgeDisplay({ return (
- {isPaginated ? ( + {isPaginated && showText ? (
{badgeExplanationText(t, bigBadge)}
) : null}
diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 193c100a2..b205f2c0f 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -1,4 +1,9 @@ -import type { Expression, ExpressionBuilder, Transaction } from "kysely"; +import type { + Expression, + ExpressionBuilder, + NotNull, + Transaction, +} from "kysely"; import { sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import * as R from "remeda"; @@ -6,15 +11,29 @@ import { db } from "~/db/sql"; import type { CalendarEventTag, DB, - PersistedCalendarEventTag, Tables, TournamentSettings, } from "~/db/tables"; +import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as Progression from "~/features/tournament-bracket/core/Progression"; -import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; +import { + databaseTimestampNow, + databaseTimestampToDate, + databaseTimestampToJavascriptTimestamp, + dateToDatabaseTimestamp, +} from "~/utils/dates"; import invariant from "~/utils/invariant"; +import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; +import { calendarEventPage, tournamentPage } from "~/utils/urls"; +import { + modesIncluded, + normalizedTeamCount, + tournamentIsRanked, +} from "../tournament/tournament-utils"; +import type { CalendarEvent } from "./calendar-types"; +import { calendarEventSorter } from "./calendar-utils"; // TODO: convert from raw to using the "exists" function const hasBadge = sql /* sql */`exists ( @@ -77,6 +96,248 @@ function tournamentOrganization(organizationId: Expression) { ); } +interface FindAllBetweenTwoTimestampsArgs { + startTime: Date; + endTime: Date; +} + +export async function findAllBetweenTwoTimestamps( + args: FindAllBetweenTwoTimestampsArgs, +) { + const rows = await findAllBetweenTwoTimestampsQuery(args); + return findAllBetweenTwoTimestampsMapped(rows); +} + +const withOrganization = (eb: ExpressionBuilder) => + jsonObjectFrom( + eb + .selectFrom("TournamentOrganization") + .select(["TournamentOrganization.name", "TournamentOrganization.slug"]) + .whereRef( + "TournamentOrganization.id", + "=", + "CalendarEvent.organizationId", + ), + ); + +const withTeamsCount = ( + eb: ExpressionBuilder, +) => + eb + .selectFrom("TournamentTeam") + .leftJoin("TournamentTeamCheckIn", (join) => + join + .on("TournamentTeamCheckIn.bracketIdx", "is", null) + .onRef( + "TournamentTeamCheckIn.tournamentTeamId", + "=", + "TournamentTeam.id", + ), + ) + .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") + .where((eb) => + eb.or([ + eb("TournamentTeamCheckIn.checkedInAt", "is not", null), + eb("CalendarEventDate.startTime", ">", databaseTimestampNow()), + ]), + ) + .select(({ fn }) => [fn.countAll().as("teamsCount")]); + +const withLogoUrl = (eb: ExpressionBuilder) => + eb + .selectFrom("UserSubmittedImage") + .select(["UserSubmittedImage.url"]) + .whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id"); + +function findAllBetweenTwoTimestampsQuery({ + startTime, + endTime, +}: FindAllBetweenTwoTimestampsArgs) { + return db + .selectFrom("CalendarEvent") + .innerJoin( + "CalendarEventDate", + "CalendarEvent.id", + "CalendarEventDate.eventId", + ) + .leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id") + .select((eb) => [ + "CalendarEvent.id as eventId", + "CalendarEvent.authorId", + "Tournament.id as tournamentId", + "Tournament.settings as tournamentSettings", + "Tournament.mapPickingStyle", + "CalendarEvent.name", + "CalendarEvent.tags", + "CalendarEventDate.startTime", + // events get grouped to their closest :00 or :30 so for example users can't make their event start at :59 to make it show at the top + sql`(("CalendarEventDate"."startTime" + 900) / 1800) * 1800`.as( + "normalizedStartTime", + ), + withOrganization(eb).as("organization"), + withTeamsCount(eb).as("teamsCount"), + withLogoUrl(eb).as("logoUrl"), + jsonArrayFrom( + eb + .selectFrom("MapPoolMap") + .select(["MapPoolMap.mode"]) + .whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"), + ).as("toSetMapPool"), + jsonArrayFrom( + eb + .selectFrom("CalendarEventBadge") + .innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id") + .select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"]) + .whereRef( + "CalendarEventBadge.eventId", + "=", + "CalendarEventDate.eventId", + ) + .orderBy("Badge.id", "asc"), + ).as("badges"), + ]) + .where("CalendarEvent.hidden", "=", 0) + .where( + "CalendarEventDate.startTime", + ">=", + dateToDatabaseTimestamp(startTime), + ) + .where( + "CalendarEventDate.startTime", + "<=", + dateToDatabaseTimestamp(endTime), + ) + .$narrowType<{ teamsCount: NotNull }>() + .execute(); +} + +function findAllBetweenTwoTimestampsMapped( + rows: Awaited>, +): Array<{ + at: number; + events: Array; +}> { + const mapped: Array = rows.map( + (row) => { + const tags = row.tags + ? (row.tags.split(",") as CalendarEvent["tags"]) + : []; + + return { + at: databaseTimestampToJavascriptTimestamp(row.startTime), + type: "calendar", + id: row.eventId, + url: row.tournamentId + ? tournamentPage(row.tournamentId) + : calendarEventPage(row.eventId), + name: row.name, + organization: row.organization, + authorId: row.authorId, + tags: tags.filter((tag) => !EXCLUDED_TAGS.includes(tag)), + teamsCount: row.teamsCount, + normalizedTeamCount: normalizedTeamCount({ + teamsCount: row.teamsCount, + minMembersPerTeam: row.tournamentSettings?.minMembersPerTeam ?? 4, + }), + modes: tags.includes("CARDS") + ? ["TB"] + : tags.includes("SR") + ? ["SR"] + : row.mapPickingStyle + ? modesIncluded(row.mapPickingStyle, row.toSetMapPool) + : null, + badges: row.badges, + logoUrl: row.logoUrl, + startTime: row.normalizedStartTime, + isRanked: row.tournamentSettings + ? tournamentIsRanked({ + isSetAsRanked: row.tournamentSettings.isRanked, + startTime: databaseTimestampToDate(row.startTime), + minMembersPerTeam: row.tournamentSettings.minMembersPerTeam ?? 4, + isTest: row.tournamentSettings.isTest ?? false, + }) + : null, + }; + }, + ); + + const grouped = R.groupBy(mapped, (row) => row.startTime); + const dates = Object.keys(grouped) + .map((dbTimestamp) => ({ + at: databaseTimestampToDate(Number(dbTimestamp)).getTime(), + events: grouped[Number(dbTimestamp)].sort(calendarEventSorter), + })) + .sort((a, b) => a.at - b.at); + + return dates; +} + +export type ForShowcase = Unwrapped; + +export function forShowcase() { + return db + .selectFrom("Tournament") + .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") + .innerJoin( + "CalendarEventDate", + "CalendarEvent.id", + "CalendarEventDate.eventId", + ) + .select((eb) => [ + "Tournament.id", + "Tournament.settings", + "CalendarEvent.authorId", + "CalendarEvent.name", + "CalendarEventDate.startTime", + withTeamsCount(eb).as("teamsCount"), + withLogoUrl(eb).as("logoUrl"), + withOrganization(eb).as("organization"), + jsonArrayFrom( + eb + .selectFrom("TournamentResult") + .innerJoin("User", "TournamentResult.userId", "User.id") + .innerJoin( + "TournamentTeam", + "TournamentResult.tournamentTeamId", + "TournamentTeam.id", + ) + .leftJoin("AllTeam", "TournamentTeam.teamId", "AllTeam.id") + .leftJoin( + "UserSubmittedImage as TeamAvatar", + "AllTeam.avatarImgId", + "TeamAvatar.id", + ) + .leftJoin( + "UserSubmittedImage as TournamentTeamAvatar", + "TournamentTeam.avatarImgId", + "TournamentTeamAvatar.id", + ) + .whereRef("TournamentResult.tournamentId", "=", "Tournament.id") + .where("TournamentResult.placement", "=", 1) + .select([ + ...COMMON_USER_FIELDS, + "User.country", + "TournamentTeam.name as teamName", + "TeamAvatar.url as teamLogoUrl", + "TournamentTeamAvatar.url as pickupAvatarUrl", + ]), + ).as("firstPlacers"), + ]) + .where("CalendarEvent.hidden", "=", 0) + .where("CalendarEventDate.startTime", ">", databaseTimestampWeekAgo()) + .orderBy("CalendarEventDate.startTime", "asc") + .$narrowType<{ teamsCount: NotNull }>() + .execute(); +} + +function databaseTimestampWeekAgo() { + const now = new Date(); + + now.setDate(now.getDate() - 7); + + return dateToDatabaseTimestamp(now); +} + export async function findById({ id, includeMapPool = false, @@ -136,121 +397,6 @@ export async function findById({ }; } -const nthAppearance = sql /* sql */` - rank() over ( - partition by "eventId" - order by - "startTime" asc -)`.as("nthAppearance"); -export type FindAllBetweenTwoTimestampsItem = Unwrapped< - typeof findAllBetweenTwoTimestamps ->; -export async function findAllBetweenTwoTimestamps({ - startTime, - endTime, - tagsToFilterBy = [], - onlyTournaments, -}: { - startTime: Date; - endTime: Date; - tagsToFilterBy?: Array; - onlyTournaments: boolean; -}) { - let query = db - .selectFrom("CalendarEvent") - .innerJoin( - "CalendarEventDate", - "CalendarEvent.id", - "CalendarEventDate.eventId", - ) - .innerJoin("User", "CalendarEvent.authorId", "User.id") - .innerJoin( - (eb) => - eb - .selectFrom("CalendarEventDate") - .select(["id", "eventId", "startTime", nthAppearance]) - .as("CalendarEventRanks"), - (join) => - join.onRef("CalendarEventRanks.id", "=", "CalendarEventDate.id"), - ) - .select(({ eb, ref }) => [ - "CalendarEvent.name", - "CalendarEvent.discordUrl", - "CalendarEvent.bracketUrl", - "CalendarEvent.tags", - "CalendarEvent.tournamentId", - "CalendarEventDate.id as eventDateId", - "CalendarEventDate.eventId", - "CalendarEventDate.startTime", - "User.username", - "CalendarEventRanks.nthAppearance", - tournamentOrganization(ref("CalendarEvent.organizationId")).as( - "organization", - ), - eb - .selectFrom("UserSubmittedImage") - .select(["UserSubmittedImage.url"]) - .whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id") - .as("logoUrl"), - eb - .selectFrom("Tournament") - .select("Tournament.settings") - .whereRef("Tournament.id", "=", "CalendarEvent.tournamentId") - .as("tournamentSettings"), - hasBadge, - jsonArrayFrom( - eb - .selectFrom("CalendarEventBadge") - .innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id") - .select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"]) - .whereRef( - "CalendarEventBadge.eventId", - "=", - "CalendarEventDate.eventId", - ), - ).as("badgePrizes"), - ]) - .where( - "CalendarEventDate.startTime", - ">=", - dateToDatabaseTimestamp(startTime), - ) - .where( - "CalendarEventDate.startTime", - "<=", - dateToDatabaseTimestamp(endTime), - ) - .where("CalendarEvent.hidden", "=", 0) - .orderBy("CalendarEventDate.startTime", "asc"); - - for (const tag of tagsToFilterBy) { - query = query.where("CalendarEvent.tags", "like", `%${tag}%`); - } - - if (onlyTournaments) { - query = query.where("CalendarEvent.tournamentId", "is not", null); - } - - const rows = await query.execute(); - - return Promise.all( - rows - .map((row) => ({ ...row, tags: tagsArray(row) })) - .map(async (row) => { - return { - ...row, - participantCounts: row.tournamentId - ? await tournamentParticipantCount({ - tournamentId: row.tournamentId, - checkedInOnly: - row.startTime < dateToDatabaseTimestamp(new Date()), - }) - : undefined, - }; - }), - ); -} - export async function findRecentTournamentsByAuthorId(authorId: number) { return db .selectFrom("CalendarEvent") @@ -280,105 +426,9 @@ function tagsArray(args: { args.tags ? args.tags.split(",") : [] ) as Array; - if (args.hasBadge) tags.unshift("BADGE"); - return tags; } -async function tournamentParticipantCount({ - tournamentId, - checkedInOnly, -}: { - tournamentId: number; - checkedInOnly: boolean; -}) { - const rows = await db - .selectFrom("TournamentTeam") - .leftJoin( - "TournamentTeamMember", - "TournamentTeam.id", - "TournamentTeamMember.tournamentTeamId", - ) - .$if(checkedInOnly, (qb) => - qb.innerJoin("TournamentTeamCheckIn", (join) => - join - .onRef( - "TournamentTeamCheckIn.tournamentTeamId", - "=", - "TournamentTeam.id", - ) - .on("TournamentTeamCheckIn.bracketIdx", "is", null), - ), - ) - .select(({ fn }) => fn.countAll().as("memberCount")) - .where("TournamentTeam.tournamentId", "=", tournamentId) - .groupBy("TournamentTeam.id") - .execute(); - - return { - teams: rows.length, - players: R.sum(rows.map((row) => row.memberCount)), - }; -} - -export async function startTimesOfRange({ - startTime, - endTime, - tagsToFilterBy, - onlyTournaments, -}: { - startTime: Date; - endTime: Date; - tagsToFilterBy: Array; - onlyTournaments: boolean; -}) { - let query = db - .selectFrom("CalendarEventDate") - .innerJoin("CalendarEvent", "CalendarEvent.id", "CalendarEventDate.eventId") - .select(["startTime"]) - .where("startTime", ">=", dateToDatabaseTimestamp(startTime)) - .where("startTime", "<=", dateToDatabaseTimestamp(endTime)); - - for (const tag of tagsToFilterBy) { - query = query.where("CalendarEvent.tags", "like", `%${tag}%`); - } - - if (onlyTournaments) { - query = query.where("CalendarEvent.tournamentId", "is not", null); - } - - const rows = await query.execute(); - return rows.map((row) => row.startTime); -} - -export async function eventsToReport(authorId: number) { - const oneMonthAgo = new Date(); - oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); - - const rows = await db - .selectFrom("CalendarEvent") - .innerJoin( - "CalendarEventDate", - "CalendarEvent.id", - "CalendarEventDate.eventId", - ) - .select(({ fn }) => [ - "CalendarEvent.id", - "CalendarEvent.name", - fn.max("CalendarEventDate.startTime").as("startTime"), - ]) - .where("CalendarEvent.authorId", "=", authorId) - .where("CalendarEvent.hidden", "=", 0) - .where("startTime", ">=", dateToDatabaseTimestamp(oneMonthAgo)) - .where("startTime", "<=", dateToDatabaseTimestamp(new Date())) - .where("CalendarEvent.participantCount", "is", null) - .where("CalendarEvent.tournamentId", "is", null) - .groupBy("CalendarEvent.id") - .execute(); - - return rows.map((row) => ({ id: row.id, name: row.name })); -} - export async function findRecentMapPoolsByAuthorId(authorId: number) { const rows = await db .selectFrom("CalendarEvent") diff --git a/app/features/calendar/actions/calendar.$id.report-winners.server.ts b/app/features/calendar/actions/calendar.$id.report-winners.server.ts index 0d1c90f4b..49cae90b7 100644 --- a/app/features/calendar/actions/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/actions/calendar.$id.report-winners.server.ts @@ -5,20 +5,22 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv import { errorToastIfFalsy, notFoundIfFalsy, + parseParams, safeParseRequestFormData, } from "~/utils/remix.server"; import { calendarEventPage } from "~/utils/urls"; -import { - reportWinnersActionSchema, - reportWinnersParamsSchema, -} from "../calendar-schemas"; +import { idObject } from "~/utils/zod"; +import { reportWinnersActionSchema } from "../calendar-schemas"; import { canReportCalendarEventWinners } from "../calendar-utils"; -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUserId(request); - const parsedParams = reportWinnersParamsSchema.parse(params); +export const action: ActionFunction = async (args) => { + const user = await requireUserId(args.request); + const params = parseParams({ + params: args.params, + schema: idObject, + }); const parsedInput = await safeParseRequestFormData({ - request, + request: args.request, schema: reportWinnersActionSchema, }); @@ -29,7 +31,7 @@ export const action: ActionFunction = async ({ request, params }) => { } const event = notFoundIfFalsy( - await CalendarRepository.findById({ id: parsedParams.id }), + await CalendarRepository.findById({ id: params.id }), ); errorToastIfFalsy( canReportCalendarEventWinners({ @@ -41,7 +43,7 @@ export const action: ActionFunction = async ({ request, params }) => { ); await CalendarRepository.upsertReportedScores({ - eventId: parsedParams.id, + eventId: params.id, participantCount: parsedInput.data.participantCount, results: parsedInput.data.team.map((t) => ({ teamName: t.teamName, @@ -53,5 +55,5 @@ export const action: ActionFunction = async ({ request, params }) => { })), }); - throw redirect(calendarEventPage(parsedParams.id)); + throw redirect(calendarEventPage(params.id)); }; diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index fca253cff..5fcda9357 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -3,7 +3,7 @@ import { redirect } from "@remix-run/node"; import type { CalendarEventTag } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas"; +import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { notify } from "~/features/notifications/core/notify.server"; diff --git a/app/features/calendar/actions/calendar.tsx b/app/features/calendar/actions/calendar.tsx new file mode 100644 index 000000000..deb5d635a --- /dev/null +++ b/app/features/calendar/actions/calendar.tsx @@ -0,0 +1,35 @@ +import { type ActionFunctionArgs, redirect } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { + parseRequestPayload, + parseSafeSearchParams, +} from "~/utils/remix.server"; +import { calendarPage } from "~/utils/urls"; +import { dayMonthYear } from "~/utils/zod"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = await requireUserId(request); + const data = await parseRequestPayload({ + request, + schema: calendarFiltersSearchParamsSchema, + }); + + await UserRepository.updatePreferences(user.id, { + defaultCalendarFilters: data, + }); + + const parsedSearchParams = parseSafeSearchParams({ + request, + schema: dayMonthYear, + }); + + return redirect( + calendarPage({ + dayMonthYear: parsedSearchParams.success + ? parsedSearchParams.data + : undefined, + }), + ); +}; diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts index 5dde8c591..91f416c57 100644 --- a/app/features/calendar/calendar-constants.ts +++ b/app/features/calendar/calendar-constants.ts @@ -1,6 +1,6 @@ -import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/tables"; +import type { CalendarEventTag } from "~/db/tables"; -export const persistedTags = { +export const tags = { SPECIAL: { color: "#CE93D8", }, @@ -41,7 +41,7 @@ export const persistedTags = { color: "#1ADB1E", }, TRIOS: { - color: "#571ADB", + color: "#B694FF", }, S1: { color: "#E5E4E2", @@ -60,13 +60,6 @@ export const persistedTags = { }, }; -export const tags = { - ...persistedTags, - BADGE: { - color: "#000", - }, -}; - export const CALENDAR_EVENT = { NAME_MIN_LENGTH: 2, NAME_MAX_LENGTH: 100, @@ -76,10 +69,6 @@ export const CALENDAR_EVENT = { BRACKET_URL_MAX_LENGTH: 200, MAX_AMOUNT_OF_DATES: 5, /** Calendar event tag that is persisted in the database */ - PERSISTED_TAGS: Object.keys( - persistedTags, - ) as Array, - /** Calendar event tag, both those persisted in the database and those that are computed */ TAGS: Object.keys(tags) as Array, AVATAR_SIZE: 512, }; @@ -103,3 +92,14 @@ export const REG_CLOSES_AT_OPTIONS = [ ] as const; export type RegClosesAtOption = (typeof REG_CLOSES_AT_OPTIONS)[number]; + +/** How many days are shown at the /calendar page at a time */ +export const DAYS_SHOWN_AT_A_TIME = 4; + +/** Tags not shown on the tournament cards */ +export const EXCLUDED_TAGS: Array = [ + "CARDS", + "SR", + "SZ", + "TW", +]; diff --git a/app/features/calendar/calendar-hooks.ts b/app/features/calendar/calendar-hooks.ts new file mode 100644 index 000000000..8c341c25e --- /dev/null +++ b/app/features/calendar/calendar-hooks.ts @@ -0,0 +1,142 @@ +import * as React from "react"; +import { calendarEventSorter } from "~/features/calendar/calendar-utils"; +import type { CalendarLoaderData } from "~/features/calendar/loaders/calendar.server"; + +interface CollapsedEvents { + eventsShown: CalendarLoaderData["eventTimes"][number]["events"]["shown"]; + date: { + from: Date; + to?: Date; + }; + hiddenShown: boolean; + hiddenCount: number; + onToggleHidden: () => void; +} + +/** + * Custom hook to manage hiding/showing calendar events. Handles collapsing adjacent times given both of them have only hidden events. + * E.g. 10pm with all hidden and 11pm with all hidden should be shown in one section as 10pm - 11pm to the user. + * + * @param eventTimes - Array of event times as returned by the calendar data loader. + */ +export function useCollapsableEvents( + eventTimes: CalendarLoaderData["eventTimes"], +) { + const [collapsingDisabled, setCollapsingDisabled] = React.useState(false); + const [shownEventTimes, setShownEventTimes] = React.useState( + new Set(), + ); + + const eventsResult: Array = []; + const hiddenTimes: Set = new Set(); + + for (const [i, eventTime] of eventTimes.entries()) { + if (hiddenTimes.has(eventTime.at)) { + continue; + } + + const { containedTimes, date } = !collapsingDisabled + ? resolveCollapsedDateRange(eventTimes, i) + : { + containedTimes: new Set().add(eventTime.at), + date: { + from: new Date(eventTime.at), + to: undefined, + }, + }; + + for (const time of containedTimes) { + hiddenTimes.add(time); + } + + const hiddenShown = shownEventTimes.has(eventTime.at); + + eventsResult.push({ + eventsShown: hiddenShown + ? [...eventTime.events.shown, ...eventTime.events.hidden].sort( + calendarEventSorter, + ) + : eventTime.events.shown, + date, + hiddenShown, + onToggleHidden: () => { + // if we clicked a range section, uncollapse it + if (containedTimes.size > 1) { + setCollapsingDisabled(true); + } + setShownEventTimes((prev) => { + const newSet = new Set(prev); + if (newSet.has(eventTime.at)) { + for (const time of containedTimes) { + newSet.delete(time); + } + } else { + for (const time of containedTimes) { + newSet.add(time); + } + } + return newSet; + }); + }, + hiddenCount: !date.to + ? eventTime.events.hidden.length + : eventTimes.reduce((acc, cur) => { + if (cur.at < eventTime.at || cur.at > date.to.getTime()) { + return acc; + } + return acc + cur.events.hidden.length; + }, 0), + }); + } + + return eventsResult; +} + +function resolveCollapsedDateRange( + eventTimes: CalendarLoaderData["eventTimes"], + forIndex: number, +) { + if (eventTimes[forIndex].events.shown.length > 0) { + return { + containedTimes: new Set().add(eventTimes[forIndex].at), + date: { + from: new Date(eventTimes[forIndex].at), + to: undefined, + }, + }; + } + + const containedTimes: Set = new Set(); + let to: Date | undefined; + + for (let j = forIndex + 1; j < eventTimes.length; j++) { + const eventTime = eventTimes[j]; + + if (eventTime.events.shown.length > 0) { + break; + } + + to = new Date(eventTime.at); + containedTimes.add(eventTime.at); + } + + containedTimes.add(eventTimes[forIndex].at); + + if (!to) { + return { + containedTimes, + date: { + from: new Date(eventTimes[forIndex].at), + to: undefined, + }, + }; + } + + return { + containedTimes, + date: { + from: new Date(eventTimes[forIndex].at), + to, + }, + }; +} diff --git a/app/features/calendar/calendar-schemas.server.ts b/app/features/calendar/calendar-schemas.server.ts new file mode 100644 index 000000000..0c76209a5 --- /dev/null +++ b/app/features/calendar/calendar-schemas.server.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import "~/styles/calendar-new.css"; +import { + bracketProgressionSchema, + calendarEventTagSchema, +} from "~/features/calendar/calendar-schemas"; +import { + actualNumber, + checkboxValueToBoolean, + date, + falsyToNull, + id, + processMany, + removeDuplicates, + safeJSONParse, + toArray, +} from "~/utils/zod"; +import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants"; +import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils"; + +export const newCalendarEventActionSchema = z + .object({ + eventToEditId: z.preprocess(actualNumber, id.nullish()), + tournamentToCopyId: z.preprocess(actualNumber, id.nullish()), + organizationId: z.preprocess(actualNumber, id.nullish()), + name: z + .string() + .min(CALENDAR_EVENT.NAME_MIN_LENGTH) + .max(CALENDAR_EVENT.NAME_MAX_LENGTH), + description: z.preprocess( + falsyToNull, + z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(), + ), + rules: z.preprocess( + falsyToNull, + z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(), + ), + date: z.preprocess( + toArray, + z + .array( + z.preprocess( + date, + z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()), + ), + ) + .min(1) + .max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES), + ), + bracketUrl: z + .string() + .url() + .max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH) + .default("https://sendou.ink"), + discordInviteCode: z.preprocess( + falsyToNull, + z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(), + ), + tags: z.preprocess( + processMany(safeJSONParse, removeDuplicates), + z.array(calendarEventTagSchema).nullable(), + ), + badges: z.preprocess( + processMany(safeJSONParse, removeDuplicates), + z.array(id).nullable(), + ), + avatarImgId: id.nullish(), + pool: z.string().optional(), + toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()), + toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(), + isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), + isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), + regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(), + enableNoScreenToggle: z.preprocess( + checkboxValueToBoolean, + z.boolean().nullish(), + ), + enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), + autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), + strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), + isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), + requireInGameNames: z.preprocess( + checkboxValueToBoolean, + z.boolean().nullish(), + ), + minMembersPerTeam: z.coerce.number().int().min(1).max(4).nullish(), + bracketProgression: bracketProgressionSchema.nullish(), + }) + .refine( + async (schema) => { + if (schema.eventToEditId) { + const eventToEdit = await CalendarRepository.findById({ + id: schema.eventToEditId, + }); + return schema.date.length === 1 || !eventToEdit?.tournamentId; + } + return schema.date.length === 1 || !schema.toToolsEnabled; + }, + { + message: "Tournament must have exactly one date", + }, + ) + .refine( + (schema) => { + if (schema.toToolsMode !== "ALL") { + return true; + } + + const maps = schema.pool ? MapPool.toDbList(schema.pool) : []; + + return ( + maps.length === 4 && + rankedModesShort.every((mode) => maps.some((map) => map.mode === mode)) + ); + }, + { + message: + 'Map pool must contain a map for each ranked mode if using "Prepicked by teams - All modes"', + }, + ); diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index eb4aeab02..2aa862f4e 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -1,29 +1,95 @@ import { z } from "zod"; import { CALENDAR_EVENT_RESULT } from "~/constants"; -import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { type CalendarEventTag, TOURNAMENT_STAGE_TYPES } from "~/db/tables"; import * as Progression from "~/features/tournament-bracket/core/Progression"; -import "~/styles/calendar-new.css"; -import { - type PersistedCalendarEventTag, - TOURNAMENT_STAGE_TYPES, -} from "~/db/tables"; -import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; -import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import "~/styles/calendar-new.css"; +import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; +import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; import { actualNumber, - checkboxValueToBoolean, - date, - falsyToNull, + gamesShortSchema, id, - processMany, - removeDuplicates, + modeShortWithSpecial, safeJSONParse, - safeSplit, toArray, } from "~/utils/zod"; -import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants"; -import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils"; +import { CALENDAR_EVENT } from "./calendar-constants"; +import * as CalendarEvent from "./core/CalendarEvent"; + +export const calendarEventTagSchema = z + .string() + .refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag)); + +const calendarFiltersPlainStringArr = z.array(z.string().max(100)).max(10); +const calendarFiltersIdsArr = z.array(id).max(10); +const calendarFilterGamesArr = z.array(gamesShortSchema).min(1).max(3); +const preferredStartTime = z.enum(["ANY", "EU", "NA", "AU"]); +const preferredVersus = z + .array(z.enum(versusShort)) + .min(1) + .max(versusShort.length); +const modeArr = z + .array(modeShortWithSpecial) + .min(1) + .max(modesShortWithSpecial.length); + +export const calendarFiltersSearchParamsSchema = z.object({ + preferredStartTime: preferredStartTime.catch("ANY"), + tagsIncluded: z.array(calendarEventTagSchema).catch([]), + tagsExcluded: z.array(calendarEventTagSchema).catch([]), + isSendou: z.boolean().catch(false), + isRanked: z.boolean().catch(false), + orgsIncluded: calendarFiltersPlainStringArr.catch([]), + orgsExcluded: calendarFiltersPlainStringArr.catch([]), + authorIdsExcluded: calendarFiltersIdsArr.catch([]), + games: calendarFilterGamesArr.catch([...gamesShort]), + preferredVersus: preferredVersus.catch([...versusShort]), + modes: modeArr.catch([...modesShortWithSpecial]), + modesExact: z.boolean().catch(false), + minTeamCount: z.coerce.number().int().nonnegative().catch(0), +}); + +export const calendarFiltersFormSchema = z + .object({ + preferredStartTime: preferredStartTime, + tagsIncluded: z.array(calendarEventTagSchema), + tagsExcluded: z.array(calendarEventTagSchema), + isSendou: z.boolean(), + isRanked: z.boolean(), + orgsIncluded: calendarFiltersPlainStringArr, + orgsExcluded: calendarFiltersPlainStringArr, + authorIdsExcluded: calendarFiltersIdsArr, + games: calendarFilterGamesArr, + preferredVersus: preferredVersus, + modes: modeArr, + modesExact: z.boolean(), + minTeamCount: z.coerce.number().int().nonnegative(), + }) + .superRefine((filters, ctx) => { + if ( + filters.tagsIncluded.some((tag) => filters.tagsExcluded.includes(tag)) + ) { + ctx.addIssue({ + path: ["tagsExcluded"], + message: "Can't include and exclude the same tag", + code: z.ZodIssueCode.custom, + }); + } + + if (filters.orgsIncluded.length > 0 && filters.orgsExcluded.length > 0) { + ctx.addIssue({ + path: ["orgsExcluded"], + message: "Can't both include and exclude organizations", + code: z.ZodIssueCode.custom, + }); + } + }); +export const calendarFiltersSearchParamsObject = z.object({ + filters: z + .preprocess(safeJSONParse, calendarFiltersSearchParamsSchema) + .catch(CalendarEvent.defaultFilters()), +}); const playersSchema = z .array( @@ -86,29 +152,6 @@ export const reportWinnersActionSchema = z.object({ ), }); -export const reportWinnersParamsSchema = z.object({ - id: z.preprocess(actualNumber, id), -}); - -export const loaderWeekSearchParamsSchema = z.object({ - week: z.preprocess(actualNumber, z.number().int().min(1).max(53)), - year: z.preprocess(actualNumber, z.number().int()), -}); - -export const calendarEventTagSchema = z - .string() - .refine((val) => - CALENDAR_EVENT.PERSISTED_TAGS.includes(val as PersistedCalendarEventTag), - ); - -export const loaderFilterSearchParamsSchema = z.object({ - tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)), -}); - -export const loaderTournamentsOnlySearchParamsSchema = z.object({ - tournaments: z.literal("true").nullish(), -}); - export const bracketProgressionSchema = z.preprocess( safeJSONParse, z @@ -140,104 +183,3 @@ export const bracketProgressionSchema = z.preprocess( "Invalid bracket progression", ), ); - -export const newCalendarEventActionSchema = z - .object({ - eventToEditId: z.preprocess(actualNumber, id.nullish()), - tournamentToCopyId: z.preprocess(actualNumber, id.nullish()), - organizationId: z.preprocess(actualNumber, id.nullish()), - name: z - .string() - .min(CALENDAR_EVENT.NAME_MIN_LENGTH) - .max(CALENDAR_EVENT.NAME_MAX_LENGTH), - description: z.preprocess( - falsyToNull, - z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(), - ), - rules: z.preprocess( - falsyToNull, - z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(), - ), - date: z.preprocess( - toArray, - z - .array( - z.preprocess( - date, - z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()), - ), - ) - .min(1) - .max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES), - ), - bracketUrl: z - .string() - .url() - .max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH) - .default("https://sendou.ink"), - discordInviteCode: z.preprocess( - falsyToNull, - z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(), - ), - tags: z.preprocess( - processMany(safeJSONParse, removeDuplicates), - z.array(calendarEventTagSchema).nullable(), - ), - badges: z.preprocess( - processMany(safeJSONParse, removeDuplicates), - z.array(id).nullable(), - ), - avatarImgId: id.nullish(), - pool: z.string().optional(), - toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()), - toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(), - isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(), - enableNoScreenToggle: z.preprocess( - checkboxValueToBoolean, - z.boolean().nullish(), - ), - enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - requireInGameNames: z.preprocess( - checkboxValueToBoolean, - z.boolean().nullish(), - ), - minMembersPerTeam: z.coerce.number().int().min(1).max(4).nullish(), - bracketProgression: bracketProgressionSchema.nullish(), - }) - .refine( - async (schema) => { - if (schema.eventToEditId) { - const eventToEdit = await CalendarRepository.findById({ - id: schema.eventToEditId, - }); - return schema.date.length === 1 || !eventToEdit?.tournamentId; - } - return schema.date.length === 1 || !schema.toToolsEnabled; - }, - { - message: "Tournament must have exactly one date", - }, - ) - .refine( - (schema) => { - if (schema.toToolsMode !== "ALL") { - return true; - } - - const maps = schema.pool ? MapPool.toDbList(schema.pool) : []; - - return ( - maps.length === 4 && - rankedModesShort.every((mode) => maps.some((map) => map.mode === mode)) - ); - }, - { - message: - 'Map pool must contain a map for each ranked mode if using "Prepicked by teams - All modes"', - }, - ); diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts index 13ced662b..74fc00b36 100644 --- a/app/features/calendar/calendar-types.ts +++ b/app/features/calendar/calendar-types.ts @@ -1,4 +1,58 @@ -export interface FollowUpBracket { +import type { z } from "zod"; +import type { CalendarEventTag, Tables } from "~/db/tables"; +import type { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas"; +import type { ModeShortWithSpecial } from "~/modules/in-game-lists/types"; +import type { CommonUser } from "~/utils/kysely.server"; + +interface CommonEvent { + id: number; name: string; - placements: Array; + teamsCount: number; + logoUrl: string | null; + url: string; + /** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */ + isRanked: boolean | null; + modes: Array | null; + organization: { + name: string; + slug: string; + } | null; + /** User id of the author of the event */ + authorId: number; } + +export interface CalendarEvent extends CommonEvent { + /** The date of the event in UNIX timestamp (JS format) */ + at: number; + type: "calendar"; + tags: Array; + /** Used for comparison, teams count where it is taken in account whether the tournament is 4v4, 3v3, 2v2 or 1v1 */ + normalizedTeamCount: number; + /** For multi-day tournaments, which day of the event is this */ + day?: number; + badges: Array< + Pick + > | null; +} + +export interface ShowcaseCalendarEvent extends CommonEvent { + type: "showcase"; + startTime: number; + firstPlacer: { + teamName: string; + logoUrl: string | null; + members: (CommonUser & { country: Tables["User"]["country"] })[]; + notShownMembersCount: number; + } | null; +} + +export interface GroupedCalendarEvents { + /** The date of the event in UNIX timestamp (JS format) */ + at: number; + events: { + shown: CalendarEvent[]; + hidden: CalendarEvent[]; + }; +} + +export type CalendarFilters = z.infer; diff --git a/app/features/calendar/calendar-utils.ts b/app/features/calendar/calendar-utils.ts index 8488cf8ed..5b5b36621 100644 --- a/app/features/calendar/calendar-utils.ts +++ b/app/features/calendar/calendar-utils.ts @@ -4,7 +4,12 @@ import { allTruthy } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; -import type { RegClosesAtOption } from "./calendar-constants"; +import type { DayMonthYear } from "~/utils/zod"; +import { + DAYS_SHOWN_AT_A_TIME, + type RegClosesAtOption, +} from "./calendar-constants"; +import type { CalendarEvent } from "./calendar-types"; export const calendarEventMinDate = () => new Date(Date.UTC(2015, 4, 28)); export const calendarEventMaxDate = () => { @@ -192,3 +197,72 @@ function eventStartedInThePast( databaseTimestampToDate(startTime).getTime() < new Date().getTime(), ); } + +export function daysForCalendar(currentDate?: DayMonthYear) { + type DaysArray = Array; + + const previous: DaysArray = []; + const shown: DaysArray = []; + const next: DaysArray = []; + + const startDate = () => + currentDate + ? new Date(currentDate.year, currentDate.month, currentDate.day) + : new Date(); + + const currentDayMonthYear = () => { + const now = startDate(); + + return { + day: now.getDate(), + month: now.getMonth(), + year: now.getFullYear(), + }; + }; + + let now = startDate(); + + for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) { + shown.push({ + day: now.getDate(), + month: now.getMonth(), + year: now.getFullYear(), + }); + + now.setDate(now.getDate() + 1); + } + + for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) { + next.push({ + day: now.getDate(), + month: now.getMonth(), + year: now.getFullYear(), + }); + + now.setDate(now.getDate() + 1); + } + + now = startDate(); + + for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) { + now.setDate(now.getDate() - 1); + + previous.push({ + day: now.getDate(), + month: now.getMonth(), + year: now.getFullYear(), + }); + } + previous.reverse(); + + return { + previous, + shown, + next, + current: currentDayMonthYear(), + }; +} + +export function calendarEventSorter(a: CalendarEvent, b: CalendarEvent) { + return b.normalizedTeamCount - a.normalizedTeamCount; +} diff --git a/app/features/calendar/components/FiltersDialog.tsx b/app/features/calendar/components/FiltersDialog.tsx new file mode 100644 index 000000000..8234e97ea --- /dev/null +++ b/app/features/calendar/components/FiltersDialog.tsx @@ -0,0 +1,216 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useFetcher, useSearchParams } from "@remix-run/react"; +import * as React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { SubmitButton } from "~/components/SubmitButton"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { InputFormField } from "~/components/form/InputFormField"; +import { InputGroupFormField } from "~/components/form/InputGroupFormField"; +import { TextArrayFormField } from "~/components/form/TextArrayFormField"; +import { ToggleFormField } from "~/components/form/ToggleFormField"; +import { FilterFilledIcon } from "~/components/icons/FilterFilled"; +import type { CalendarEventTag } from "~/db/tables"; +import { useUser } from "~/features/auth/core/user"; +import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas"; +import type { CalendarFilters } from "~/features/calendar/calendar-types"; +import { TagsFormField } from "~/features/calendar/components/TagsFormField"; + +export function FiltersDialog({ filters }: { filters: CalendarFilters }) { + const { t } = useTranslation(["calendar"]); + const [isOpen, setIsOpen] = React.useState(false); + + return ( + <> + } + onClick={() => setIsOpen(true)} + data-testid="filter-events-button" + > + {t("calendar:filter.button")} + + setIsOpen(false)} + > + { + setIsOpen(false); + }} + /> + + + ); +} + +const TAGS_TO_OMIT: Array = [ + "CARDS", + "SR", + "S1", + "S2", + "SZ", + "TW", + "ONES", + "DUOS", + "TRIOS", +] as const; + +function FiltersForm({ + filters, + closeDialog, +}: { filters: CalendarFilters; closeDialog: () => void }) { + const user = useUser(); + const { t } = useTranslation(["game-misc", "calendar"]); + const methods = useForm({ + resolver: zodResolver(calendarFiltersFormSchema), + defaultValues: filters, + }); + const fetcher = useFetcher(); + const [, setSearchParams] = useSearchParams(); + + const filtersToSearchParams = (newFilters: CalendarFilters) => { + setSearchParams((prev) => { + prev.set("filters", JSON.stringify(newFilters)); + return prev; + }); + }; + + const onApply = React.useCallback( + methods.handleSubmit((values) => { + filtersToSearchParams(values); + closeDialog(); + }), + [], + ); + + const onApplyAndPersist = React.useCallback( + methods.handleSubmit((values) => + fetcher.submit(values, { method: "post", encType: "application/json" }), + ), + [], + ); + + return ( + + + + type="checkbox" + label={t("calendar:filter.modes")} + name={"modes" as const} + values={[ + { label: t("game-misc:MODE_LONG_TW"), value: "TW" }, + { label: t("game-misc:MODE_LONG_SZ"), value: "SZ" }, + { label: t("game-misc:MODE_LONG_TC"), value: "TC" }, + { label: t("game-misc:MODE_LONG_RM"), value: "RM" }, + { label: t("game-misc:MODE_LONG_CB"), value: "CB" }, + { label: t("game-misc:MODE_LONG_SR"), value: "SR" }, + { label: t("game-misc:MODE_LONG_TB"), value: "TB" }, + ]} + /> + + + label={t("calendar:filter.exactModes")} + name={"modesExact" as const} + bottomText={t("calendar:filter.exactModesBottom")} + /> + + + type="checkbox" + label={t("calendar:filter.games")} + name={"games" as const} + values={[ + { label: t("game-misc:GAME_S1"), value: "S1" }, + { label: t("game-misc:GAME_S2"), value: "S2" }, + { label: t("game-misc:GAME_S3"), value: "S3" }, + ]} + /> + + + type="checkbox" + label={t("calendar:filter.vs")} + name={"preferredVersus" as const} + values={[ + { label: "4v4", value: "4v4" }, + { label: "3v3", value: "3v3" }, + { label: "2v2", value: "2v2" }, + { label: "1v1", value: "1v1" }, + ]} + /> + + + type="radio" + label={t("calendar:filter.startTime")} + name={"preferredStartTime" as const} + values={[ + { label: t("calendar:filter.startTime.any"), value: "ANY" }, + { label: t("calendar:filter.startTime.eu"), value: "EU" }, + { label: t("calendar:filter.startTime.na"), value: "NA" }, + { label: t("calendar:filter.startTime.au"), value: "AU" }, + ]} + /> + + + label={t("calendar:filter.tagsIncluded")} + name={"tagsIncluded" as const} + tagsToOmit={TAGS_TO_OMIT} + /> + + + label={t("calendar:filter.tagsExcluded")} + name={"tagsExcluded" as const} + tagsToOmit={TAGS_TO_OMIT} + /> + + + label={t("calendar:filter.isSendou")} + name={"isSendou" as const} + /> + + + label={t("calendar:filter.isRanked")} + name={"isRanked" as const} + /> + + + label={t("calendar:filter.minTeamCount")} + type="number" + name={"minTeamCount" as const} + /> + + + label={t("calendar:filter.orgsIncluded")} + name={"orgsIncluded" as const} + /> + + + label={t("calendar:filter.orgsExcluded")} + name={"orgsExcluded" as const} + /> + + + label={t("calendar:filter.authorIdsExcluded")} + name={"authorIdsExcluded" as const} + bottomText={t("calendar:filter.authorIdsExcludedBottom")} + /> + +
+ onApply()}> + {t("calendar:filter.apply")} + + {user ? ( + + {t("calendar:filter.applyAndDefault")} + + ) : null} +
+
+
+ ); +} diff --git a/app/features/calendar/components/Tags.tsx b/app/features/calendar/components/Tags.tsx index a8f06d092..82a4dac3d 100644 --- a/app/features/calendar/components/Tags.tsx +++ b/app/features/calendar/components/Tags.tsx @@ -1,45 +1,35 @@ import clsx from "clsx"; import React from "react"; import { useTranslation } from "react-i18next"; -import { Badge } from "~/components/Badge"; import { Button } from "~/components/Button"; import { CrossIcon } from "~/components/icons/Cross"; import type { CalendarEventTag } from "~/db/tables"; -import type * as CalendarRepository from "../CalendarRepository.server"; import { tags as allTags } from "../calendar-constants"; export function Tags({ tags, - badges, onDelete, - tournamentRankedStatus, + small = false, + centered = false, }: { tags: Array; - badges?: CalendarRepository.FindAllBetweenTwoTimestampsItem["badgePrizes"]; - tournamentRankedStatus?: "RANKED" | "UNRANKED"; + small?: boolean; + centered?: boolean; /** Called when tag delete button clicked. If undefined delete buttons won't be shown. */ onDelete?: (tag: CalendarEventTag) => void; }) { const { t } = useTranslation(); - if (tags.length === 0 && !tournamentRankedStatus) return null; + if (tags.length === 0) return null; return ( -
    - {tournamentRankedStatus === "RANKED" ? ( -
  • Ranked
  • - ) : null} - {tournamentRankedStatus === "UNRANKED" ? ( -
  • Unranked
  • - ) : null} +
      {tags.map((tag) => (
    • {t(`tag.name.${tag}`)} {onDelete && ( @@ -52,13 +42,6 @@ export function Tags({ size="tiny" /> )} - {tag === "BADGE" && badges && ( -
      - {badges.map((badge) => ( - - ))} -
      - )}
    • ))} diff --git a/app/features/calendar/components/TagsFormField.module.css b/app/features/calendar/components/TagsFormField.module.css new file mode 100644 index 000000000..7578975e3 --- /dev/null +++ b/app/features/calendar/components/TagsFormField.module.css @@ -0,0 +1,34 @@ +.tagGroup { + width: 100%; +} + +.tagList { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: var(--s-2); +} + +.tag { + border: 2px solid var(--tag-color); + border-radius: var(--rounded-sm); + font-size: var(--fonts-sm); + font-weight: var(--semi-bold); + display: grid; + place-items: center; + min-width: max-content; +} + +.tag[data-selected] { + background-color: var(--tag-color) !important; + color: var(--black-text); +} + +.tag[data-focus-visible] { + outline: 2px solid var(--tag-color); + outline-offset: 1px; + background-color: var(--bg-lighter); +} + +.tag[data-hovered] { + background-color: var(--bg-lighter); +} diff --git a/app/features/calendar/components/TagsFormField.tsx b/app/features/calendar/components/TagsFormField.tsx new file mode 100644 index 000000000..fcbe07a7c --- /dev/null +++ b/app/features/calendar/components/TagsFormField.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; +import { Tag, TagGroup, TagList } from "react-aria-components"; +import { + Controller, + type FieldPath, + type FieldValues, + get, + useFormContext, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { FormMessage } from "~/components/FormMessage"; +import { Label } from "~/components/Label"; +import type { CalendarEventTag } from "~/db/tables"; +import { CALENDAR_EVENT } from "~/features/calendar/calendar-constants"; +import { tags as allTags } from "../calendar-constants"; + +import styles from "./TagsFormField.module.css"; + +export function TagsFormField({ + label, + name, + bottomText, + tagsToOmit, +}: { + label: string; + name: FieldPath; + bottomText?: string; + tagsToOmit?: Array; +}) { + const methods = useFormContext(); + const id = React.useId(); + + const error = get(methods.formState.errors, name); + + return ( +
      + + ( + + )} + /> + {error && ( + {error.message as string} + )} + {bottomText && !error ? ( + {bottomText} + ) : null} +
      + ); +} + +export const SelectableTags = React.forwardRef< + HTMLDivElement, + { + selectedTags: Array; + tagsToOmit?: Array; + onSelectionChange: (selectedTags: Array) => void; + } +>(({ selectedTags, tagsToOmit, onSelectionChange }, ref) => { + const { t } = useTranslation(); + + const availableTags = tagsToOmit + ? CALENDAR_EVENT.TAGS.filter((tag) => !tagsToOmit?.includes(tag)) + : CALENDAR_EVENT.TAGS; + + return ( + + onSelectionChange(Array.from(newSelection) as CalendarEventTag[]) + } + aria-label="Select tags" + ref={ref} + > + + {availableTags.map((tag) => { + return ( + + {t(`tag.name.${tag}`)} + + ); + })} + + + ); +}); diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css new file mode 100644 index 000000000..ada1084ff --- /dev/null +++ b/app/features/calendar/components/TournamentCard.module.css @@ -0,0 +1,153 @@ +.container { + min-width: var(--card-width); + max-width: var(--card-width); + height: calc(var(--card-height) + 26px); + display: flex; + flex-direction: column; + gap: var(--s-1); + --tournament-card-icon-size: 13px; +} + +.containerTall { + height: 100%; +} + +.card { + background-color: var(--bg-lightest); + border-radius: var(--rounded); + color: var(--text); + padding: var(--s-1) var(--s-2) var(--s-2) var(--s-2); + height: 100%; + min-width: var(--card-width); + max-width: var(--card-width); +} + +.card:hover .avatarImg { + outline: 6px solid var(--theme-transparent); +} + +.imgContainer { + background-color: var(--bg); + padding: var(--s-1-5); + border-radius: 100%; + margin-left: -10px; + margin-top: -10px; +} + +.avatarImg { + border-radius: 100%; +} + +.org { + display: flex; + gap: var(--s-1); + font-size: var(--fonts-xxs); + color: var(--text-lighter); +} + +.time { + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + color: var(--text-lighter); + display: block; +} + +.name { + text-align: center; + font-weight: var(--semi-bold); + font-size: var(--fonts-sm); + white-space: nowrap; + text-overflow: ellipsis; + max-width: 225px; + overflow: hidden; +} + +.teamCount { + font-size: var(--fonts-xxs); + font-weight: var(--bold); + background-color: var(--bg-lightest); + border-radius: var(--rounded-sm); + width: max-content; + padding: var(--s-0-5) var(--s-1-5); + display: flex; + gap: var(--s-1); +} + +.teamCount svg { + width: var(--tournament-card-icon-size); +} + +.modesPillContainer { + padding-inline-start: 12px; +} + +.modesPill { + background-color: var(--bg-lightest); + border-radius: var(--rounded-sm); + width: max-content; + padding: var(--s-0-5) var(--s-1-5); + display: flex; + gap: var(--s-1); +} + +.pillsContainer { + display: flex; + gap: var(--s-1); + justify-content: end; + padding-inline-end: 12px; +} + +.pill { + font-size: var(--fonts-xxs); + font-weight: var(--bold); + border-radius: var(--rounded-sm); + padding: var(--s-0-5) var(--s-1-5); + display: grid; + place-items: center; +} + +.pillRanked { + background-color: var(--theme-info-transparent); + color: var(--theme-info); +} + +.pillRanked svg { + width: var(--tournament-card-icon-size); +} + +.firstPlacers { + margin-inline: auto; + margin-top: var(--s-5); + width: max-content; +} + +.firstPlacersTeamName { + max-width: 150px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badgesContainer { + display: flex; + gap: var(--s-1); + justify-content: center; + background-color: black; + border-radius: var(--rounded-sm); + padding: var(--s-1); +} + +.badgeNavIcon { + min-width: 16px; +} + +.badgeDisplay { + min-width: initial; + min-height: initial; +} + +.badgePill { + background-color: var(--bg-lightest); + border-radius: var(--rounded-sm); + padding: var(--s-0-5) var(--s-1-5); +} diff --git a/app/features/calendar/components/TournamentCard.tsx b/app/features/calendar/components/TournamentCard.tsx new file mode 100644 index 000000000..c302382b0 --- /dev/null +++ b/app/features/calendar/components/TournamentCard.tsx @@ -0,0 +1,207 @@ +import { Link } from "@remix-run/react"; +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { Flag } from "~/components/Flag"; +import { Image, ModeImage } from "~/components/Image"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { TrophyIcon } from "~/components/icons/Trophy"; +import { UsersIcon } from "~/components/icons/Users"; +import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; +import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { navIconUrl, userSubmittedImage } from "~/utils/urls"; +import type { CalendarEvent, ShowcaseCalendarEvent } from "../calendar-types"; +import { Tags } from "./Tags"; +import styles from "./TournamentCard.module.css"; + +export function TournamentCard({ + tournament, + className, +}: { + tournament: CalendarEvent | ShowcaseCalendarEvent; + className?: string; +}) { + const isMounted = useIsMounted(); + const { i18n } = useTranslation(["front", "common"]); + + const isShowcase = tournament.type === "showcase"; + const isCalendar = tournament.type === "calendar"; + const isHostedOnSendouInk = typeof tournament.isRanked === "boolean"; + + const time = () => { + if (!isShowcase) return null; + if (!isMounted) return "Placeholder"; + + const date = databaseTimestampToDate(tournament.startTime); + return date.toLocaleString(i18n.language, { + month: "short", + day: "numeric", + hour: "numeric", + weekday: "short", + minute: date.getMinutes() !== 0 ? "numeric" : undefined, + }); + }; + + return ( +
      + +
      + {isHostedOnSendouInk ? ( +
      + +
      + ) : null} + {tournament.organization ? ( +
      {tournament.organization.name}
      + ) : null} +
      +
      + {tournament.name}{" "} + {isShowcase ? ( + + ) : null} +
      + {isCalendar ? ( +
      + +
      + ) : null} + {isShowcase && tournament.firstPlacer ? ( + + ) : null} + +
      + {tournament.modes ? : null} + {isHostedOnSendouInk ? ( +
      + {tournament.isRanked ? ( +
      + +
      + ) : null} + {isCalendar && tournament.badges && tournament.badges.length > 0 ? ( + + ) : null} +
      + {tournament.teamsCount} +
      +
      + ) : null} +
      +
      + ); +} + +function TournamentFirstPlacers({ + firstPlacer, +}: { + firstPlacer: NonNullable; +}) { + const { t } = useTranslation(["front"]); + + return ( +
      +
      + {firstPlacer.logoUrl ? ( + + ) : null}{" "} +
      + + {firstPlacer.teamName} + +
      + {t("front:showcase.card.winner")} +
      +
      +
      +
      + {firstPlacer.members.map((member) => ( +
      + {member.country ? : null} + {member.username}{" "} +
      + ))} + {firstPlacer.notShownMembersCount > 0 ? ( +
      + +{firstPlacer.notShownMembersCount} +
      + ) : null} +
      +
      + ); +} + +function ModesPill({ modes }: { modes: NonNullable }) { + const size = 16; + + return ( +
      +
      + {modes.map((mode) => ( + + ))} +
      +
      + ); +} + +function BadgePrizesPill({ + badges, +}: { badges: NonNullable }) { + return ( + + Badge prizes + + } + > + + + ); +} diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts new file mode 100644 index 000000000..a538c2bc5 --- /dev/null +++ b/app/features/calendar/core/CalendarEvent.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import type { + CalendarEvent as CalendarEventType, + CalendarFilters, +} from "../calendar-types"; +import * as CalendarEvent from "./CalendarEvent"; + +function makeEvent( + overrides: Partial = {}, +): CalendarEventType { + return { + at: new Date().getTime(), + id: 1, + isRanked: null, + tags: [], + modes: ["SZ"], + teamsCount: 2, + organization: null, + authorId: 1, + type: "calendar", + normalizedTeamCount: 0, + badges: [], + logoUrl: null, + name: "", + url: "", + ...overrides, + }; +} + +describe("CalendarEvent.applyFilters", () => { + it("returns all events as shown with default filters", () => { + const events = [ + { + at: 123, + events: [makeEvent({ id: 1 }), makeEvent({ id: 2 })], + }, + ]; + const result = CalendarEvent.applyFilters( + events, + CalendarEvent.defaultFilters(), + ); + expect(result[0].events.shown).toHaveLength(2); + expect(result[0].events.hidden).toHaveLength(0); + }); + + it("filters by isRanked", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, isRanked: true }), + makeEvent({ id: 2, isRanked: false }), + makeEvent({ id: 3, isRanked: null }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + isRanked: true, + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown).toHaveLength(1); + expect(result[0].events.shown[0].id).toBe(1); + expect(result[0].events.hidden).toHaveLength(2); + }); + + it("filters by tagsIncluded", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, tags: ["MONEY", "ART"] }), + makeEvent({ id: 2, tags: ["LOW"] }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + tagsIncluded: ["MONEY"], + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown).toHaveLength(1); + expect(result[0].events.shown[0].id).toBe(1); + }); + + it("filters by tagsExcluded", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, tags: ["MONEY"] }), + makeEvent({ id: 2, tags: ["ART"] }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + tagsExcluded: ["MONEY"], + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown).toHaveLength(1); + expect(result[0].events.shown[0].id).toBe(2); + }); + + it("filters by games", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, tags: ["S1"] }), + makeEvent({ id: 2, tags: ["S2"] }), + makeEvent({ id: 3, tags: [] }), // S3 + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + games: ["S1"], + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown).toHaveLength(1); + expect(result[0].events.shown[0].id).toBe(1); + }); + + it("filters by preferredVersus", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, tags: ["ONES"] }), + makeEvent({ id: 2, tags: ["DUOS"] }), + makeEvent({ id: 3, tags: ["TRIOS"] }), + makeEvent({ id: 4, tags: [] }), // 4v4 + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + preferredVersus: ["1v1", "2v2"], + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([1, 2]); + }); + + it("filters by modes (not exact)", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, modes: ["SZ"] }), + makeEvent({ id: 2, modes: ["TC"] }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + modes: ["SZ"], + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([1]); + }); + + it("filters by modes (exact)", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, modes: ["SZ", "TC"] }), + makeEvent({ id: 2, modes: ["SZ"] }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + modes: ["SZ"], + modesExact: true, + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([2]); + }); + + it("filters by minTeamCount", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, teamsCount: 2 }), + makeEvent({ id: 2, teamsCount: 4 }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + minTeamCount: 3, + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([2]); + }); + + it("filters by orgsIncluded", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, organization: { name: "OrgA" } as any }), + makeEvent({ id: 2, organization: { name: "OrgB" } as any }), + makeEvent({ id: 3, organization: undefined }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + orgsIncluded: ["OrgA"], + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([1]); + }); + + it("filters by orgsExcluded", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, organization: { name: "OrgA" } as any }), + makeEvent({ id: 2, organization: { name: "OrgB" } as any }), + makeEvent({ id: 3, organization: undefined }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + orgsExcluded: ["OrgA"], + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([2, 3]); + }); + + it("filters by authorIdsExcluded", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, authorId: 1 }), + makeEvent({ id: 2, authorId: 2 }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + authorIdsExcluded: [1], + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([2]); + }); + + it("filters by combining two different filters", () => { + const events = [ + { + at: 123, + events: [ + makeEvent({ id: 1, tags: ["MONEY"], isRanked: true }), + makeEvent({ id: 2, tags: ["LOW"], isRanked: false }), + makeEvent({ id: 3, tags: ["MONEY"], isRanked: false }), + ], + }, + ]; + const filters: CalendarFilters = { + ...CalendarEvent.defaultFilters(), + tagsIncluded: ["MONEY"], + isRanked: true, + }; + const result = CalendarEvent.applyFilters(events, filters); + expect(result[0].events.shown.map((e) => e.id)).toEqual([1]); + }); +}); diff --git a/app/features/calendar/core/CalendarEvent.ts b/app/features/calendar/core/CalendarEvent.ts new file mode 100644 index 000000000..6dd80c95e --- /dev/null +++ b/app/features/calendar/core/CalendarEvent.ts @@ -0,0 +1,303 @@ +import { TZDate } from "@date-fns/tz"; +import { isWeekend } from "date-fns"; +import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; +import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; +import { assertType } from "~/utils/types"; +import type { + CalendarEvent, + CalendarFilters, + GroupedCalendarEvents, +} from "../calendar-types"; + +const FILTERS_KEYS = [ + "preferredStartTime", + "tagsIncluded", + "tagsExcluded", + "isSendou", + "isRanked", + "games", + "orgsIncluded", + "orgsExcluded", + "authorIdsExcluded", + "modes", + "modesExact", + "minTeamCount", + "preferredVersus", +] as const; + +assertType<(typeof FILTERS_KEYS)[number], keyof CalendarFilters>(); +assertType(); + +/** + * Returns the default empty state of filter settings for calendar events. + */ +export function defaultFilters(): CalendarFilters { + return { + preferredStartTime: "ANY", + tagsIncluded: [], + tagsExcluded: [], + isSendou: false, + isRanked: false, + games: [...gamesShort], + modes: [...modesShortWithSpecial], + preferredVersus: [...versusShort], + modesExact: false, + orgsIncluded: [], + orgsExcluded: [], + authorIdsExcluded: [], + minTeamCount: 0, + }; +} + +const defaultFiltersString = filtersToString(defaultFilters()); + +/** + * Determines whether the provided calendar filters match the default filter settings. + */ +export function isDefaultFilters(filters: CalendarFilters): boolean { + return filtersToString(filters) === defaultFiltersString; +} + +/** + * Serializes the given calendar filters object into a string representation to be used as e.g. React key. + */ +export function filtersToString(filters: CalendarFilters): string { + let result = ""; + + for (const key of FILTERS_KEYS) { + result += `${key}-${filters[key]};`; + } + + return result; +} + +/** + * Applies the provided calendar filters to a list of grouped calendar events. + * + * Returns a new array where each group contains separated lists of shown and hidden events. Event is shown if it matches all set filters. + */ +export function applyFilters( + events: { + at: number; + events: Array; + }[], + filters: CalendarFilters, +): Array { + return events.map((eventTime) => { + const shown: CalendarEvent[] = []; + const hidden: CalendarEvent[] = []; + + for (const calendarEvent of eventTime.events) { + let isHidden = false; + for (const key of FILTERS_KEYS) { + if (!matchesFilter(calendarEvent, eventTime.at, key, filters)) { + isHidden = true; + break; + } + } + if (isHidden) { + hidden.push(calendarEvent); + continue; + } + + shown.push(calendarEvent); + } + + return { + at: eventTime.at, + events: { + shown, + hidden, + }, + }; + }); +} + +function matchesFilter( + event: CalendarEvent, + startTime: number, + key: keyof CalendarFilters, + filters: CalendarFilters, +): boolean { + switch (key) { + case "preferredStartTime": { + const preferredStartTime = filters[key]; + if (preferredStartTime === "ANY") { + return true; + } + + const timeZone = + preferredStartTime === "EU" + ? "Europe/Paris" + : preferredStartTime === "NA" + ? "America/Winnipeg" + : "Australia/Perth"; + + const tzDate = new TZDate(startTime, timeZone); + + if (isWeekend(tzDate)) { + return tzDate.getHours() >= 10 && tzDate.getHours() <= 24; + } + + return tzDate.getHours() >= 16 && tzDate.getHours() <= 23; + } + case "isSendou": { + if (filters[key] !== true) { + return true; + } + + return event.isRanked !== null; + } + case "isRanked": { + if (filters[key] !== true) { + return true; + } + + return event.isRanked === true; + } + case "tagsIncluded": { + const tags = filters[key]; + if (tags.length === 0) { + return true; + } + + return event.tags.some((tag) => tags.includes(tag)); + } + case "tagsExcluded": { + const tags = filters[key]; + if (tags.length === 0) { + return true; + } + + return !event.tags.some((tag) => tags.includes(tag)); + } + case "games": { + const games = filters[key]; + if (!games.length || games.length === gamesShort.length) { + return true; + } + const isSplatoonOne = event.tags.includes("S1"); + const isSplatoonTwo = event.tags.includes("S2"); + const isSplatoonThree = !isSplatoonOne && !isSplatoonTwo; + + for (const game of games) { + if (game === "S1" && isSplatoonOne) { + return true; + } + if (game === "S2" && isSplatoonTwo) { + return true; + } + if (game === "S3" && isSplatoonThree) { + return true; + } + } + + return false; + } + case "preferredVersus": { + const preferredVersus = filters[key]; + if ( + !preferredVersus.length || + preferredVersus.length === versusShort.length + ) { + return true; + } + + const eventType = event.tags.find( + (tag) => tag === "ONES" || tag === "DUOS" || tag === "TRIOS", + ); + + if (eventType === "ONES") { + return preferredVersus.includes("1v1"); + } + + if (eventType === "DUOS") { + return preferredVersus.includes("2v2"); + } + + if (eventType === "TRIOS") { + return preferredVersus.includes("3v3"); + } + + return preferredVersus.includes("4v4"); + } + case "modes": { + const modes = filters[key]; + if (!modes.length || modes.length === modesShortWithSpecial.length) { + return true; + } + + if (!event.modes) { + return false; + } + + if (filters.modesExact) { + return ( + event.modes.length === modes.length && + event.modes.every((mode) => modes.includes(mode)) + ); + } + + return event.modes.some((mode) => modes.includes(mode)); + } + case "modesExact": { + // handled in the modes filter + return true; + } + case "minTeamCount": { + const minTeamCount = filters[key]; + if (minTeamCount === 0) { + return true; + } + + return event.teamsCount >= minTeamCount; + } + case "orgsIncluded": { + const orgsIncluded = filters[key]; + if (orgsIncluded.length === 0) { + return true; + } + + const org = event.organization; + + if (!org) { + return false; + } + + return orgsIncluded.some((orgName) => + orgNameMatches({ orgName: org.name, value: orgName }), + ); + } + case "orgsExcluded": { + const orgsExcluded = filters[key]; + if (orgsExcluded.length === 0) { + return true; + } + + const org = event.organization; + + if (!org) { + return true; + } + + return !orgsExcluded.some((orgName) => + orgNameMatches({ orgName: org.name, value: orgName }), + ); + } + case "authorIdsExcluded": { + const authorIds = filters[key]; + if (authorIds.length === 0) { + return true; + } + + return !authorIds.some((id) => event.authorId === id); + } + } +} + +function orgNameMatches({ + orgName, + value, +}: { orgName: string; value: string }) { + return orgName.trim().toLowerCase() === value.trim().toLowerCase(); +} diff --git a/app/features/calendar/core/ICal.server.ts b/app/features/calendar/core/ICal.server.ts new file mode 100644 index 000000000..8eeac07bc --- /dev/null +++ b/app/features/calendar/core/ICal.server.ts @@ -0,0 +1,51 @@ +import * as ics from "ics"; +import type { CalendarEvent } from "~/features/calendar/calendar-types"; +import { logger } from "~/utils/logger"; +import { SENDOU_INK_BASE_URL } from "~/utils/urls"; + +export async function getICalendar(events: Array) { + // ical doesnt allow calendars with no events + if (events.length === 0) { + logger.warn("Could not construct ical feed, no events within time period"); + return null; + } + + const { error, value } = eventsAsICal(events); + + if (error) { + logger.error(`Error constructing ical feed: ${error}`); + return null; + } + + if (!value) { + logger.error("Error constructing ical feed: no value returned"); + return null; + } + + return value; +} + +function eventsAsICal(events: Array): ics.ReturnObject { + return ics.createEvents(events.map(eventInfoAsICalEvent)); +} + +function eventInfoAsICalEvent(event: CalendarEvent): ics.EventAttributes { + const startDate = new Date(event.at); + const eventLink = `${SENDOU_INK_BASE_URL}/${event.url}`; + + return { + title: event.name, + start: [ + startDate.getUTCFullYear(), + startDate.getUTCMonth() + 1, + startDate.getUTCDate(), + startDate.getUTCHours(), + startDate.getUTCMinutes(), + ], + startInputType: "utc", + duration: { hours: 3 }, // arbitrary length + url: eventLink, + categories: event.tags, + productId: "sendou.ink/calendar", + }; +} diff --git a/app/features/calendar/core/ical.ts b/app/features/calendar/core/ical.ts deleted file mode 100644 index 11fce4195..000000000 --- a/app/features/calendar/core/ical.ts +++ /dev/null @@ -1,81 +0,0 @@ -import * as ics from "ics"; -import type { PersistedCalendarEventTag } from "~/db/tables"; -import { databaseTimestampToDate } from "~/utils/dates"; -import { logger } from "~/utils/logger"; -import { CALENDAR_PAGE, SENDOU_INK_BASE_URL } from "~/utils/urls"; -import { - type FindAllBetweenTwoTimestampsItem, - findAllBetweenTwoTimestamps, -} from "../CalendarRepository.server"; - -export async function getICalendar( - { - tagsFilter, - tournamentsFilter, - }: { - tagsFilter: Array; - tournamentsFilter: boolean; - } = { tagsFilter: [], tournamentsFilter: false }, -): Promise { - const startTime = new Date(); - const endTime = new Date(startTime); - - // get all events over the next month, might be good to make this an parameter in the future - endTime.setDate(startTime.getDate() + 30); - - // handle timezone mismatch between server and client - startTime.setHours(startTime.getHours() - 12); - endTime.setHours(endTime.getHours() + 12); - - const events = await findAllBetweenTwoTimestamps({ - startTime, - endTime, - tagsToFilterBy: tagsFilter, - onlyTournaments: tournamentsFilter, - }); - - // ical doesnt allow calendars with no events - if (events.length === 0) { - logger.warn("Could not construct ical feed, no events within time period"); - return null; - } - - const { error, value } = eventsAsICal(events); - - if (error) { - logger.error(`Error constructing ical feed: ${error}`); - return null; - } - - return value as string; -} - -export function eventsAsICal( - events: Array, -): ics.ReturnObject { - return ics.createEvents(events.map(eventInfoAsICalEvent)); -} - -export function eventInfoAsICalEvent( - event: FindAllBetweenTwoTimestampsItem, -): ics.EventAttributes { - const startDate = databaseTimestampToDate(event.startTime); - const eventLink = `${SENDOU_INK_BASE_URL}${CALENDAR_PAGE}/${event.eventId}`; - const tags = event.tags; - - return { - title: event.name, - start: [ - startDate.getUTCFullYear(), - startDate.getUTCMonth() + 1, - startDate.getUTCDate(), - startDate.getUTCHours(), - startDate.getUTCMinutes(), - ], - startInputType: "utc", - duration: { hours: 3 }, // arbitrary length - url: eventLink, - categories: tags, - productId: "sendou.ink/calendar", - }; -} diff --git a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts index 89cb9f83b..1fcdd1ec1 100644 --- a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts @@ -1,15 +1,22 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { requireUserId } from "~/features/auth/core/user.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import { notFoundIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server"; -import { reportWinnersParamsSchema } from "../calendar-schemas"; +import { + notFoundIfFalsy, + parseParams, + unauthorizedIfFalsy, +} from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; import { canReportCalendarEventWinners } from "../calendar-utils"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const parsedParams = reportWinnersParamsSchema.parse(params); - const user = await requireUserId(request); +export const loader = async (args: LoaderFunctionArgs) => { + const params = parseParams({ + params: args.params, + schema: idObject, + }); + const user = await requireUserId(args.request); const event = notFoundIfFalsy( - await CalendarRepository.findById({ id: parsedParams.id }), + await CalendarRepository.findById({ id: params.id }), ); unauthorizedIfFalsy( @@ -23,6 +30,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return { name: event.name, participantCount: event.participantCount, - winners: await CalendarRepository.findResultsByEventId(parsedParams.id), + winners: await CalendarRepository.findResultsByEventId(params.id), }; }; diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts index 33eff0479..c1bd52ebc 100644 --- a/app/features/calendar/loaders/calendar.new.server.ts +++ b/app/features/calendar/loaders/calendar.new.server.ts @@ -28,14 +28,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { if (!event) return; - // special tags that are added automatically - const tags = event?.tags?.filter((tag) => tag !== "BADGE"); - - if (!event?.tournamentId) return { ...event, tags, tournament: null }; + if (!event?.tournamentId) return { ...event, tournament: null }; return { ...event, - tags, tournament: await tournamentData({ tournamentId: event.tournamentId, user, diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts index 1d392ceca..ecf1e7249 100644 --- a/app/features/calendar/loaders/calendar.server.ts +++ b/app/features/calendar/loaders/calendar.server.ts @@ -1,105 +1,71 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import { addMonths, subMonths } from "date-fns"; -import type { PersistedCalendarEventTag } from "~/db/tables"; -import { getUserId } from "~/features/auth/core/user.server"; +import type { UserPreferences } from "~/db/tables"; +import { getUser } from "~/features/auth/core/user.server"; +import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants"; import { - dateToThisWeeksMonday, - dateToThisWeeksSunday, - dateToWeekNumber, - weekNumberToDate, -} from "~/utils/dates"; + calendarFiltersSearchParamsObject, + calendarFiltersSearchParamsSchema, +} from "~/features/calendar/calendar-schemas"; +import type { SerializeFrom } from "~/utils/remix"; +import { parseSafeSearchParams, parseSearchParams } from "~/utils/remix.server"; +import { dayMonthYear } from "~/utils/zod"; import * as CalendarRepository from "../CalendarRepository.server"; -import { - loaderFilterSearchParamsSchema, - loaderTournamentsOnlySearchParamsSchema, - loaderWeekSearchParamsSchema, -} from "../calendar-schemas"; -import { closeByWeeks } from "../calendar-utils"; +import * as CalendarEvent from "../core/CalendarEvent"; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await getUserId(request); - const url = new URL(request.url); +export type CalendarLoaderData = SerializeFrom; - // separate from tags parse so they can fail independently - const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({ - year: url.searchParams.get("year"), - week: url.searchParams.get("week"), +export const loader = async (args: LoaderFunctionArgs) => { + const user = await getUser(args.request); + const parsed = parseSafeSearchParams({ + request: args.request, + schema: dayMonthYear, }); - const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({ - tags: url.searchParams.get("tags"), + + const date = parsed.success + ? new Date( + Date.UTC(parsed.data.year, parsed.data.month, parsed.data.day), + ).getTime() + : Date.now(); + + const twentyFourHoursAgo = date - 24 * 60 * 60 * 1000; + const fiveDaysFromNow = date + DAYS_SHOWN_AT_A_TIME * 24 * 60 * 60 * 1000; + + const events = await CalendarRepository.findAllBetweenTwoTimestamps({ + startTime: new Date(twentyFourHoursAgo), + endTime: new Date(fiveDaysFromNow), }); - const parsedTournamentsOnlyParams = - loaderTournamentsOnlySearchParamsSchema.safeParse({ - tournaments: url.searchParams.get("tournaments"), - }); - const mondayDate = dateToThisWeeksMonday(new Date()); - const sundayDate = dateToThisWeeksSunday(new Date()); - const currentWeek = dateToWeekNumber(mondayDate); - - const displayedWeek = parsedWeekParams.success - ? parsedWeekParams.data.week - : currentWeek; - const displayedYear = parsedWeekParams.success - ? parsedWeekParams.data.year - : currentWeek === 1 // handle first week of the year special case - ? sundayDate.getFullYear() - : mondayDate.getFullYear(); - const tagsToFilterBy = parsedFilterParams.success - ? (parsedFilterParams.data.tags as PersistedCalendarEventTag[]) - : []; - const onlyTournaments = parsedTournamentsOnlyParams.success - ? Boolean(parsedTournamentsOnlyParams.data.tournaments) - : false; + const filters = resolveFilters(args.request, user?.preferences); + const filtered = CalendarEvent.applyFilters(events, filters); return { - currentWeek, - displayedWeek, - currentDay: new Date().getDay(), - nearbyStartTimes: await CalendarRepository.startTimesOfRange({ - startTime: subMonths( - weekNumberToDate({ week: displayedWeek, year: displayedYear }), - 1, - ), - endTime: addMonths( - weekNumberToDate({ week: displayedWeek, year: displayedYear }), - 1, - ), - tagsToFilterBy, - onlyTournaments, - }), - weeks: closeByWeeks({ week: displayedWeek, year: displayedYear }), - events: await fetchEventsOfWeek({ - week: displayedWeek, - year: displayedYear, - tagsToFilterBy, - onlyTournaments, - }), - eventsToReport: user - ? await CalendarRepository.eventsToReport(user.id) - : [], + eventTimes: filtered, + dateViewed: parsed.success ? parsed.data : undefined, + filters, }; }; -function fetchEventsOfWeek(args: { - week: number; - year: number; - tagsToFilterBy: PersistedCalendarEventTag[]; - onlyTournaments: boolean; -}) { - const startTime = weekNumberToDate(args); +function resolveFilters( + request: Request, + preferences?: UserPreferences | null, +) { + const parsed = parseSearchParams({ + request, + schema: calendarFiltersSearchParamsObject, + }).filters; - const endTime = new Date(startTime); - endTime.setDate(endTime.getDate() + 7); + if (!CalendarEvent.isDefaultFilters(parsed)) { + return parsed; + } - // handle timezone mismatch between server and client - startTime.setHours(startTime.getHours() - 12); - endTime.setHours(endTime.getHours() + 12); + if (preferences?.defaultCalendarFilters) { + // make sure the saved values still match current reality + const parsedDefault = calendarFiltersSearchParamsSchema.parse( + preferences.defaultCalendarFilters, + ); - return CalendarRepository.findAllBetweenTwoTimestamps({ - startTime, - endTime, - tagsToFilterBy: args.tagsToFilterBy, - onlyTournaments: args.onlyTournaments, - }); + return parsedDefault; + } + + return CalendarEvent.defaultFilters(); } diff --git a/app/features/calendar/loaders/calendar[.]ics.server.ts b/app/features/calendar/loaders/calendar[.]ics.server.ts index 5c1071cd0..720318788 100644 --- a/app/features/calendar/loaders/calendar[.]ics.server.ts +++ b/app/features/calendar/loaders/calendar[.]ics.server.ts @@ -1,34 +1,36 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import type { PersistedCalendarEventTag } from "~/db/tables"; -import { - loaderFilterSearchParamsSchema, - loaderTournamentsOnlySearchParamsSchema, -} from "../calendar-schemas"; -import * as ical from "../core/ical"; +import { parseSearchParams } from "~/utils/remix.server"; +import * as CalendarRepository from "../CalendarRepository.server"; +import { calendarFiltersSearchParamsObject } from "../calendar-schemas"; +import * as CalendarEvent from "../core/CalendarEvent"; +import * as ICal from "../core/ICal.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); + const filters = parseSearchParams({ + request, + schema: calendarFiltersSearchParamsObject, + }).filters; - // allows limiting calendar events to specific tags - const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({ - tags: url.searchParams.get("tags"), + const startTime = new Date(); + const endTime = new Date(startTime); + + // get all events over the two weeks, might be good to make this an parameter in the future + endTime.setDate(startTime.getDate() + 14); + + // handle timezone mismatch between server and client + startTime.setHours(startTime.getHours() - 12); + endTime.setHours(endTime.getHours() + 12); + + const events = await CalendarRepository.findAllBetweenTwoTimestamps({ + startTime, + endTime, }); - const parsedTournamentsOnlyParams = - loaderTournamentsOnlySearchParamsSchema.safeParse({ - tournaments: url.searchParams.get("tournaments"), - }); - const tagsToFilterBy = parsedFilterParams.success - ? (parsedFilterParams.data.tags as PersistedCalendarEventTag[]) - : []; - const onlyTournaments = parsedTournamentsOnlyParams.success - ? Boolean(parsedTournamentsOnlyParams.data.tournaments) - : false; + const filtered = CalendarEvent.applyFilters(events, filters); - const iCalData = await ical.getICalendar({ - tagsFilter: tagsToFilterBy, - tournamentsFilter: onlyTournaments, - }); + const iCalData = await ICal.getICalendar( + filtered.flatMap((eventTime) => eventTime.events.shown), + ); if (iCalData === null) { return new Response(null, { status: 204 }); diff --git a/app/features/calendar/routes/calendar.$id.tsx b/app/features/calendar/routes/calendar.$id.tsx index b93390614..020756e2f 100644 --- a/app/features/calendar/routes/calendar.$id.tsx +++ b/app/features/calendar/routes/calendar.$id.tsx @@ -120,7 +120,7 @@ export default function CalendarEventPage() {

      {data.event.name}

      - +
      {data.event.discordUrl ? ( diff --git a/app/features/calendar/routes/calendar.module.css b/app/features/calendar/routes/calendar.module.css new file mode 100644 index 000000000..2d42a6489 --- /dev/null +++ b/app/features/calendar/routes/calendar.module.css @@ -0,0 +1,127 @@ +.buttonsContainer { + display: flex; + justify-content: space-between; + gap: var(--s-6); + align-items: start; + flex-wrap: wrap-reverse; +} + +.navigateButtonsContainer { + display: flex; + gap: var(--s-4); + width: 100%; +} + +.navigateButton { + all: unset; + background-color: var(--bg-lightest); + border: 2px solid var(--border); + display: flex; + align-items: center; + font-weight: var(--bold); + border-radius: var(--rounded-sm); + padding: var(--s-1) var(--s-2); + font-size: var(--fonts-xs); + flex: 1 1 0; +} + +.navigateButton:focus-visible { + outline: 2px solid var(--theme); +} + +@media screen and (min-width: 600px) { + .navigateButton { + flex: initial; + } + + .navigateButtonsContainer { + width: initial; + } +} + +.navigateButton:not(.navigateArrowButton) { + justify-content: center; +} + +.navigateButton svg { + min-width: 1.75rem; + max-width: 1.75rem; + min-width: 1.75rem; + max-height: 1.75rem; + stroke-width: 2px; +} + +.navigateArrowButton svg { + margin-inline-start: -5px; +} + +.majorLink { + all: unset; + background-color: orange; + border: 2px solid darkorange; + color: rgb(49, 49, 49); + font-weight: var(--bold); + border-radius: var(--rounded-sm); + padding: var(--s-1) var(--s-2); + font-size: var(--fonts-xs); + position: relative; +} + +.columnsContainer { + display: grid; + grid-template-columns: repeat(var(--columns-count), 225px); + gap: var(--s-14); + overflow-x: auto; +} + +.dayHeader { + border: 2px solid var(--border); + border-radius: var(--rounded-sm); + background-color: var(--bg-lighter); + padding: var(--s-2); + text-align: center; + font-size: var(--fonts-md); + font-weight: var(--bold); +} + +.dayHeaderToday { + background-color: var(--bg-lightest); + border: 2px solid var(--theme-transparent); +} + +.dayHeaderWeekday { + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + color: var(--text-lighter); +} + +.dayEvents { + margin-block-start: var(--s-4); + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.clockHeader { + font-weight: var(--semi-bold); + font-size: var(--fonts-sm); +} + +.clockHeaderDivider { + width: 100%; + height: 3px; + background-color: var(--theme-transparent); + border-radius: var(--rounded); +} + +.hiddenEventsButton svg { + width: 1rem; + height: 1rem; + stroke-width: 2px; +} + +.calendar { + background-color: initial; + padding: var(--s-2); + border: none; +} diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index c22894e09..e0171dcd2 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -584,9 +584,9 @@ function TagsAdder() { const [tags, setTags] = React.useState(baseEvent?.tags ?? []); const id = React.useId(); - const tagsForSelect = CALENDAR_EVENT.PERSISTED_TAGS.filter( + const tagsForSelect = CALENDAR_EVENT.TAGS.filter( (tag) => !tags.includes(tag), - ); + ).filter((tag) => tag !== "SZ" && tag !== "TW"); // TODO: these are now added automatically, remove in migration? return (
      @@ -601,7 +601,6 @@ function TagsAdder() { id={id} className="calendar-new__select" onChange={(e) => - // @ts-expect-error TODO: fix this (5.5 version) setTags([...tags, e.target.value as CalendarEventTag]) } > @@ -612,7 +611,6 @@ function TagsAdder() { ))} - {t("calendar:forms.tags.info")}
      { - const data = args.data as SerializeFrom | null; - - if (!data) return []; - - const events = data.events.slice().sort((a, b) => { - const aParticipants = a.participantCounts?.teams ?? 0; - const bParticipants = b.participantCounts?.teams ?? 0; - - if (aParticipants > bParticipants) return -1; - if (aParticipants < bParticipants) return 1; - - return 0; - }); - return metaTags({ title: "Calendar", ogTitle: "Splatoon competitive event calendar", location: args.location, - description: `${data.events.length} events on sendou.ink happening during week ${ - data.displayedWeek - } including ${joinListToNaturalString( - events.slice(0, 3).map((e) => e.name), - )}`, + description: + "Browser Splatoon competitive tournaments and events both local and online. Events for players of all skill levels from newcomer to pro.", }); }; export const handle: SendouRouteHandle = { - i18n: "calendar", + i18n: ["calendar", "front"], breadcrumb: () => ({ imgPath: navIconUrl("calendar"), href: CALENDAR_PAGE, @@ -90,608 +61,268 @@ export const handle: SendouRouteHandle = { }; export default function CalendarPage() { - const { t } = useTranslation("calendar"); + const { t } = useTranslation(["calendar", "common"]); const data = useLoaderData(); - const isMounted = useIsMounted(); - // we don't know which events are starting in user's time zone on server - // so that's why this calculation is not in the loader - const thisWeeksEvents = isMounted - ? data.events.filter( - (event) => - dateToWeekNumber( - dateToSixHoursAgo(databaseTimestampToDate(event.startTime)), - ) === data.displayedWeek, - ) - : data.events; + const { previous, shown, next, current } = daysForCalendar(data.dateViewed); return ( -
      - - -
      -
      -
      - - -
      - +
      +
      +
      + } + daysInterval={previous} + filters={data.filters} + > + {t("common:actions.previous")} + + } + daysInterval={next} + filters={data.filters} + > + {t("common:actions.next")} + +
      - {isMounted ? ( - <> - {thisWeeksEvents.length > 0 ? ( - <> - -
      - {t("inYourTimeZone")}{" "} - {Intl.DateTimeFormat().resolvedOptions().timeZone} -
      - - ) : ( -

      {t("noEvents")}

      - )} - - ) : ( -
      - )} +
      + } size="small" variant="outlined"> + {t("calendar:icalFeed")} + + } + url={calendarIcalFeed(data.filters)} + /> + +
      +
      +
      + {shown.map((date) => ( + { + const eventDate = new Date(event.at); + + return ( + eventDate.getDate() === date.day && + eventDate.getMonth() === date.month + ); + })} + /> + ))}
      ); } -function WeekLinks() { - const data = useLoaderData(); - const isMounted = useIsMounted(); - const [searchParams] = useSearchParams(); +function NavigateButton({ + icon, + children, + daysInterval, + filters, +}: { + icon: SendouButtonProps["icon"]; + children: React.ReactNode; + daysInterval: ReturnType["shown"]; + filters?: CalendarLoaderData["filters"]; +}) { + const { i18n } = useTranslation(); + const lowestDate = daysInterval[0]; + const highestDate = daysInterval[daysInterval.length - 1]; - const eventCounts = isMounted - ? getEventsCountPerWeek(data.nearbyStartTimes) - : null; - - const linkTo = (args: { week: number; year: number }) => { - const params = new URLSearchParams(searchParams); - params.set("week", String(args.week)); - params.set("year", String(args.year)); - - return `?${params.toString()}`; - }; + const dateToString = ( + day: ReturnType["shown"][number], + ) => + new Date(new Date().getFullYear(), day.month, day.day).toLocaleDateString( + i18n.language, + { + day: "numeric", + month: "short", + }, + ); return ( - number).join("")}> -
      -
      - {data.weeks.map((week, i) => { - const hidden = [ - 0, - 1, - data.weeks.length - 2, - data.weeks.length - 1, - ].includes(i); - - const isCurrentWeek = i === 4; - - return ( - - isCurrentWeek && e.preventDefault()} - > - <> - -
      - ×{eventCounts?.get(week.number) ?? 0} -
      - - -
      - ); - })} + + {icon} +
      +
      {children}
      +
      + {dateToString(lowestDate)} - {dateToString(highestDate)}
      - + ); } -function WeekLinkTitle({ - week, +function CalendarDatePicker({ + dayMonthYear, + filters, +}: { dayMonthYear: DayMonthYear; filters?: CalendarLoaderData["filters"] }) { + const navigate = useNavigate(); + + const onChange = (date: DateValue) => { + navigate( + calendarPage({ + filters, + dayMonthYear: { + day: date.day, + month: date.month - 1, + year: date.year, + }, + }), + ); + }; + + return ( + } + /> + } + > + + + ); +} + +function DayEventsColumn({ + date, + month, + eventTimes, }: { - week: Unpacked["weeks"]>; + date: number; + month: number; + eventTimes: CalendarLoaderData["eventTimes"]; }) { - const { t } = useTranslation("calendar"); - const data = useLoaderData(); + const eventTimesCollapsed = useCollapsableEvents(eventTimes); + + return ( +
      + +
      + {eventTimesCollapsed.map((eventTime, i) => { + return ( +
      + + {eventTime.eventsShown.map((event) => ( + + ))} +
      + ); + })} +
      +
      + ); +} + +function DayHeader(props: { date: number; month: number }) { const { i18n } = useTranslation(); - const isSameYear = week.year === new Date().getFullYear(); - const relativeWeekIdentifier = - week.number === data.currentWeek && isSameYear - ? t("week.this") - : week.number - data.currentWeek === 1 && isSameYear - ? t("week.next") - : week.number - data.currentWeek === -1 && isSameYear - ? t("week.last") - : null; - - if (relativeWeekIdentifier) { - return ( -
      -
      {relativeWeekIdentifier}
      -
      - ); - } + const date = new Date(new Date().getFullYear(), props.month, props.date); + const isToday = date.toDateString() === new Date().toDateString(); return ( -
      -
      - {weekNumberToDate({ - week: week.number, - year: week.year, - }).toLocaleDateString(i18n.language, { - day: "numeric", - month: "short", - })} -
      -
      -
      -
      - {weekNumberToDate({ - week: week.number, - year: week.year, - position: "end", - }).toLocaleDateString(i18n.language, { - day: "numeric", - month: "short", - })} -
      -
      - ); -} - -function getEventsCountPerWeek( - startTimes: SerializeFrom["nearbyStartTimes"], -) { - const result = new Map(); - - for (const startTime of startTimes) { - const week = dateToWeekNumber(databaseTimestampToDate(startTime)); - const previousCount = result.get(week) ?? 0; - result.set(week, previousCount + 1); - } - - return result; -} - -function EventsToReport() { - const { t } = useTranslation("calendar"); - const data = useLoaderData(); - - if (data.eventsToReport.length === 0) return null; - - return ( - - {t("reportResults")}{" "} - {data.eventsToReport.map((event, i) => ( - - {event.name} - {i === data.eventsToReport.length - 1 ? "" : ", "} - - ))} - - ); -} - -function TagsFilter() { - const { t } = useTranslation(["calendar", "common"]); - const id = React.useId(); - const [searchParams, setSearchParams] = useSearchParams(); - - const tagsToFilterBy = (searchParams - .get("tags") - ?.split(",") - .filter((tag) => CALENDAR_EVENT.TAGS.includes(tag as CalendarEventTag)) ?? - []) as CalendarEventTag[]; - const setTagsToFilterBy = (tags: CalendarEventTag[]) => { - setSearchParams((params) => { - if (tags.length === 0) { - params.delete("tags"); - return params; - } - - params.set("tags", tags.join(",")); - return params; - }); - }; - - const tagsForSelect = CALENDAR_EVENT.PERSISTED_TAGS.filter( - (tag) => !tagsToFilterBy.includes(tag), - ); - - return ( -
      -
      - - -
      - - setTagsToFilterBy(tagsToFilterBy.filter((tag) => tag !== tagToDelete)) - } - /> -
      - ); -} - -function ICalLink() { - const [searchParams, _] = useSearchParams(); - const [state, copyToClipboard] = useCopyToClipboard(); - const [copySuccess, setCopySuccess] = React.useState(false); - - React.useEffect(() => { - if (!state.value) return; - - setCopySuccess(true); - const timeout = setTimeout(() => setCopySuccess(false), 2000); - - return () => clearTimeout(timeout); - }, [state]); - - const filteredTags = ( - searchParams - .get("tags") - ?.split(",") - .filter((tag) => CALENDAR_EVENT.TAGS.includes(tag as CalendarEventTag)) ?? - [] - ).join(); - - const onlyTournaments = searchParams.get("tournaments") === "true"; - - const params = createSearchParams(); - - if (filteredTags.length > 0) params.append("tags", filteredTags); - if (onlyTournaments) params.append("tournaments", "true"); - - const icalURL = `https://sendou.ink/calendar.ics${params.size > 0 ? `?${params.toString()}` : ""}`; - - return ( -
      - -
      - - copyToClipboard(icalURL)} - icon={copySuccess ? : } - aria-label="Copy to clipboard" - /> -
      -
      - ); -} - -function OnSendouInkToggle() { - const { t } = useTranslation(["calendar"]); - const [searchParams, setSearchParams] = useSearchParams(); - - const onlyTournaments = searchParams.get("tournaments") === "true"; - - const setOnlyTournaments = (value: boolean) => { - setSearchParams((params) => { - if (value) { - params.set("tournaments", "true"); - } else { - params.delete("tournaments"); - } - - return params; - }); - }; - - return ( -
      -
      - - -
      -
      - ); -} - -function EventsList({ - events, -}: { - events: SerializeFrom["events"]; -}) { - const data = useLoaderData(); - const { t, i18n } = useTranslation("calendar"); - - const sortPastEventsLast = data.currentWeek === data.displayedWeek; - - const eventsGrouped = eventsGroupedByDay(events); - if (sortPastEventsLast) { - eventsGrouped.sort( - pastEventsLast(dayToWeekStartsAtMondayDay(data.currentDay)), - ); - } - - let dividerRendered = false; - return ( -
      - {eventsGrouped.map(([daysDate, events]) => { - const renderDivider = - sortPastEventsLast && - !dividerRendered && - getWeekStartsAtMondayDay(daysDate) < - dayToWeekStartsAtMondayDay(data.currentDay); - if (renderDivider) { - dividerRendered = true; - } - - const sectionWeekday = daysDate.toLocaleString(i18n.language, { - weekday: "short", - }); - - return ( - -
      - {renderDivider ? ( - - {t("pastEvents.dividerText")} - - ) : null} -
      - {daysDate.toLocaleDateString(i18n.language, { - weekday: "long", - day: "numeric", - month: "long", - })} -
      -
      -
      - {events.map((calendarEvent) => { - const eventWeekday = databaseTimestampToDate( - calendarEvent.startTime, - ).toLocaleString(i18n.language, { - weekday: "short", - }); - - const isOneVsOne = - calendarEvent.tournamentSettings?.minMembersPerTeam === 1; - - const startTimeDate = databaseTimestampToDate( - calendarEvent.startTime, - ); - const tournamentRankedStatus = () => { - if (!calendarEvent.tournamentSettings) return undefined; - if (!Seasons.current(startTimeDate)) return undefined; - - return calendarEvent.tournamentSettings.isRanked && - (!calendarEvent.tournamentSettings.minMembersPerTeam || - calendarEvent.tournamentSettings.minMembersPerTeam === 4) - ? "RANKED" - : "UNRANKED"; - }; - - return ( -
      -
      -
      - - {calendarEvent.organization ? ( - - - {calendarEvent.organization.name} - - ) : ( -
      - {t("from", { - author: calendarEvent.username, - })} -
      - )} - {sectionWeekday !== eventWeekday ? ( -
      - {eventWeekday} -
      - ) : null} -
      -
      -
      - {calendarEvent.tournamentId ? ( - - ) : null} -
      - -

      - {calendarEvent.name}{" "} - {calendarEvent.nthAppearance > 1 ? ( - - {t("day", { - number: calendarEvent.nthAppearance, - })} - - ) : null} -

      - - {calendarEvent.participantCounts && - calendarEvent.participantCounts.teams > 0 ? ( -
      - {" "} - {!isOneVsOne ? ( - <> - {t("count.teams", { - count: - calendarEvent.participantCounts.teams, - })}{" "} - /{" "} - - ) : null} - {t("count.players", { - count: - calendarEvent.participantCounts.players, - })} -
      - ) : null} -
      -
      - -
      -
      -
      - {calendarEvent.discordUrl ? ( - - Discord - - ) : null} - {!calendarEvent.tournamentId ? ( - - {resolveBaseUrl(calendarEvent.bracketUrl)} - - ) : null} -
      -
      - ); - })} -
      -
      - ); +
      + {date.toLocaleDateString(i18n.language, { + day: "numeric", + month: "long", + })} +
      + {date.toLocaleDateString(i18n.language, { + weekday: "long", + })} +
      ); } -// Goal with this is to make events that start during the night for EU (NA events) -// grouped up with the previous day. Otherwise you have a past event showing at the -// top of the page for the whole following day for EU. -function dateToSixHoursAgo(date: Date) { - const sixHoursAgo = new Date(date); - sixHoursAgo.setHours(sixHoursAgo.getHours() - 6); - return sixHoursAgo; -} - -type EventsGrouped = [Date, SerializeFrom["events"]]; -function eventsGroupedByDay(events: SerializeFrom["events"]) { - const result: EventsGrouped[] = []; - - for (const calendarEvent of events) { - const previousIterationEvents = result[result.length - 1] ?? null; - - const eventsDate = dateToSixHoursAgo( - databaseTimestampToDate(calendarEvent.startTime), - ); - - if ( - !previousIterationEvents || - previousIterationEvents[0].getDay() !== eventsDate.getDay() - ) { - result.push([eventsDate, [calendarEvent]]); - } else { - previousIterationEvents[1].push(calendarEvent); - } - } - - return result; -} - -function pastEventsLast(currentDay: number) { - return (a: EventsGrouped, b: EventsGrouped) => { - const aDay = getWeekStartsAtMondayDay(a[0]); - const bDay = getWeekStartsAtMondayDay(b[0]); - - if (aDay < currentDay && bDay >= currentDay) { - return 1; - } - - if (aDay >= currentDay && bDay < currentDay) { - return -1; - } - - return 0; - }; +function ClockHeader({ + date, + toDate, + hiddenEventsCount = 0, + onToggleHidden, + hiddenShown, + className, +}: { + date: Date; + toDate?: Date; + hiddenEventsCount?: number; + onToggleHidden: () => void; + hiddenShown: boolean; + className?: string; +}) { + const { i18n } = useTranslation(); + + const isInThePast = (toDate ?? date).getTime() < Date.now(); + + return ( +
      +
      + + {date.toLocaleTimeString(i18n.language, { + hour: "numeric", + minute: "2-digit", + })} + {toDate + ? ` - ${toDate.toLocaleTimeString(i18n.language, { + hour: "numeric", + minute: "2-digit", + })}` + : ""} + + {hiddenEventsCount > 0 ? ( + : } + onClick={onToggleHidden} + variant="minimal" + className={styles.hiddenEventsButton} + data-testid="hidden-events-button" + > + {hiddenEventsCount} + + ) : null} +
      +
      +
      + ); } diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts index c40dec851..37c7b81ea 100644 --- a/app/features/front-page/core/ShowcaseTournaments.server.ts +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -1,6 +1,6 @@ import cachified from "@epic-web/cachified"; import { TWO_HOURS_IN_MS } from "~/constants"; -import type { Tables } from "~/db/tables"; +import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { tournamentIsRanked } from "~/features/tournament/tournament-utils"; import { cache, ttl } from "~/utils/cache.server"; @@ -9,36 +9,18 @@ import { dateToDatabaseTimestamp, } from "~/utils/dates"; import type { CommonUser } from "~/utils/kysely.server"; +import { tournamentPage } from "~/utils/urls"; interface ShowcaseTournamentCollection { - participatingFor: ShowcaseTournament[]; - organizingFor: ShowcaseTournament[]; - showcase: ShowcaseTournament[]; - results: ShowcaseTournament[]; -} - -export interface ShowcaseTournament { - id: number; - name: string; - startTime: number; - teamsCount: number; - isRanked: boolean; - logoUrl: string | null; - organization: { - name: string; - slug: string; - } | null; - firstPlacer: { - teamName: string; - logoUrl: string | null; - members: (CommonUser & { country: Tables["User"]["country"] })[]; - notShownMembersCount: number; - } | null; + participatingFor: ShowcaseCalendarEvent[]; + organizingFor: ShowcaseCalendarEvent[]; + showcase: ShowcaseCalendarEvent[]; + results: ShowcaseCalendarEvent[]; } interface ParticipationInfo { - participants: Set; - organizers: Set; + participants: Set; + organizers: Set; } export async function frontPageTournamentsByUserId( @@ -152,7 +134,7 @@ export function updateCachedTournamentTeamCount({ async function cachedParticipationInfo( userId: number | null, - tournaments: ShowcaseTournament[], + tournaments: ShowcaseCalendarEvent[], ): Promise { if (!userId) { return emptyParticipationInfo(); @@ -188,7 +170,7 @@ async function cachedTournaments() { }); } -function deleteExtraResults(tournaments: ShowcaseTournament[]) { +function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) { const nonResults = tournaments.filter( (tournament) => !tournament.firstPlacer, ); @@ -216,8 +198,8 @@ function deleteExtraResults(tournaments: ShowcaseTournament[]) { } function resolveShowcaseTournaments( - tournaments: ShowcaseTournament[], -): ShowcaseTournament[] { + tournaments: ShowcaseCalendarEvent[], +): ShowcaseCalendarEvent[] { const happeningDuringNextWeek = tournaments.filter( (tournament) => tournament.startTime > databaseTimestampSixHoursAgo() && @@ -237,7 +219,7 @@ function resolveShowcaseTournaments( } async function tournamentsToParticipationInfoMap( - tournaments: ShowcaseTournament[], + tournaments: ShowcaseCalendarEvent[], ): Promise> { const tournamentIds = tournaments.map((tournament) => tournament.id); const tournamentsWithUsers = @@ -284,9 +266,12 @@ const MEMBERS_TO_SHOW = 5; function mapTournamentFromDB( tournament: TournamentRepository.ForShowcase, -): ShowcaseTournament { +): ShowcaseCalendarEvent { return { + type: "showcase", + url: tournamentPage(tournament.id), id: tournament.id, + authorId: tournament.authorId, name: tournament.name, startTime: tournament.startTime, teamsCount: tournament.teamsCount, @@ -303,6 +288,7 @@ function mapTournamentFromDB( minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4, isTest: tournament.settings.isTest ?? false, }), + modes: null, // no need to show modes for front page, maybe could in the future? firstPlacer: tournament.firstPlacers.length > 0 ? { diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index 6c5450c7a..836f062c2 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next"; import { Avatar } from "~/components/Avatar"; import { Button } from "~/components/Button"; import { Divider } from "~/components/Divider"; -import { Flag } from "~/components/Flag"; import { Image } from "~/components/Image"; import { Main } from "~/components/Main"; import { NewTabs } from "~/components/NewTabs"; @@ -20,11 +19,11 @@ import { SearchIcon } from "~/components/icons/Search"; import { UsersIcon } from "~/components/icons/Users"; import { navItems } from "~/components/layout/nav-items"; import { useUser } from "~/features/auth/core/user"; +import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; +import { TournamentCard } from "~/features/calendar/components/TournamentCard"; import type * as Changelog from "~/features/front-page/core/Changelog.server"; import * as Seasons from "~/features/mmr/core/Seasons"; -import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils"; import { useIsMounted } from "~/hooks/useIsMounted"; -import { databaseTimestampToDate } from "~/utils/dates"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { BLANK_IMAGE_URL, @@ -35,10 +34,7 @@ import { leaderboardsPage, navIconUrl, sqHeaderGuyImageUrl, - tournamentPage, - userSubmittedImage, } from "~/utils/urls"; -import type * as ShowcaseTournaments from "../core/ShowcaseTournaments.server"; import { type LeaderboardEntry, loader } from "../loaders/index.server"; export { loader }; @@ -239,7 +235,7 @@ function TournamentCards() { function ShowcaseTournamentScroller({ tournaments, -}: { tournaments: ShowcaseTournaments.ShowcaseTournament[] }) { +}: { tournaments: ShowcaseCalendarEvent[] }) { return (
      @@ -247,7 +243,7 @@ function ShowcaseTournamentScroller({ ))}
      @@ -270,141 +266,6 @@ function AllTournamentsLinkCard() { ); } -function TournamentCard({ - tournament, - topSpaced, -}: { - tournament: ShowcaseTournaments.ShowcaseTournament; - topSpaced?: boolean; -}) { - const isMounted = useIsMounted(); - const { t, i18n } = useTranslation(["front", "common"]); - - const time = () => { - if (!isMounted) return "Placeholder"; - - const date = databaseTimestampToDate(tournament.startTime); - return date.toLocaleString(i18n.language, { - month: "short", - day: "numeric", - hour: "numeric", - weekday: "short", - minute: date.getMinutes() !== 0 ? "numeric" : undefined, - }); - }; - - return ( -
      - -
      -
      - -
      - {tournament.organization ? ( -
      - {tournament.organization.name} -
      - ) : null} -
      -
      - {tournament.name}{" "} - -
      - {tournament.firstPlacer ? ( - - ) : null} - -
      -
      - {tournament.teamsCount} -
      - {tournament.isRanked ? ( -
      - {t("front:showcase.card.ranked")} -
      - ) : ( -
      - {t("front:showcase.card.unranked")} -
      - )} -
      -
      - ); -} - -function TournamentFirstPlacers({ - firstPlacer, -}: { - firstPlacer: NonNullable< - ShowcaseTournaments.ShowcaseTournament["firstPlacer"] - >; -}) { - const { t } = useTranslation(["front"]); - - return ( -
      -
      - {firstPlacer.logoUrl ? ( - - ) : null}{" "} -
      - - {firstPlacer.teamName} - -
      - {t("front:showcase.card.winner")} -
      -
      -
      -
      - {firstPlacer.members.map((member) => ( -
      - {member.country ? : null} - {member.username}{" "} -
      - ))} - {firstPlacer.notShownMembersCount > 0 ? ( -
      - +{firstPlacer.notShownMembersCount} -
      - ) : null} -
      -
      - ); -} - function ResultHighlights() { const { t } = useTranslation(["front"]); const data = useLoaderData(); diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx index 99ca53045..42062db17 100644 --- a/app/features/scrims/routes/scrims.new.tsx +++ b/app/features/scrims/routes/scrims.new.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import type { z } from "zod"; import { Label } from "~/components/Label"; import { DateTimeFormField } from "~/components/form/DateTimeFormField"; -import { MyForm } from "~/components/form/MyForm"; +import { SendouForm } from "~/components/form/SendouForm"; import { TextAreaFormField } from "~/components/form/TextAreaFormField"; import { nullFilledArray } from "~/utils/arrays"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -35,7 +35,7 @@ export default function NewScrimPage() { return (
      - - +
      ); } diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index 902fabbdb..185fcfdd9 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -13,7 +13,7 @@ import { Table } from "~/components/Table"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { SendouPopover } from "~/components/elements/Popover"; -import { MyForm } from "~/components/form/MyForm"; +import { SendouForm } from "~/components/form/SendouForm"; import { EyeSlashIcon } from "~/components/icons/EyeSlash"; import { SpeechBubbleIcon } from "~/components/icons/SpeechBubble"; import { UsersIcon } from "~/components/icons/Users"; @@ -186,7 +186,7 @@ function RequestScrimModal({ return ( - - + ); } diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index f96e4c880..310d9103d 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1,4 +1,3 @@ -import * as R from "remeda"; import type { Tables, TournamentStage, @@ -10,11 +9,13 @@ import { LEAGUES, TOURNAMENT, } from "~/features/tournament/tournament-constants"; -import { tournamentIsRanked } from "~/features/tournament/tournament-utils"; +import { + modesIncluded, + tournamentIsRanked, +} from "~/features/tournament/tournament-utils"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import type { Match, Stage } from "~/modules/brackets-model"; import type { ModeShort } from "~/modules/in-game-lists"; -import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes"; import { isAdmin } from "~/modules/permissions/utils"; import { databaseTimestampNow, @@ -728,32 +729,7 @@ export class Tournament { } get modesIncluded(): ModeShort[] { - switch (this.ctx.mapPickingStyle) { - case "AUTO_SZ": { - return ["SZ"]; - } - case "AUTO_TC": { - return ["TC"]; - } - case "AUTO_RM": { - return ["RM"]; - } - case "AUTO_CB": { - return ["CB"]; - } - default: { - const pickedModes = R.unique( - this.ctx.toSetMapPool.map((map) => map.mode), - ); - if (pickedModes.length === 0) { - return [...rankedModesShort]; - } - - return pickedModes.sort( - (a, b) => modesShort.indexOf(a) - modesShort.indexOf(b), - ); - } - } + return modesIncluded(this.ctx.mapPickingStyle, this.ctx.toSetMapPool); } tournamentTeamLogoSrc(team: TournamentDataTeam) { diff --git a/app/features/tournament-bracket/core/mapList.server.ts b/app/features/tournament-bracket/core/mapList.server.ts index d254afc90..b56e800a8 100644 --- a/app/features/tournament-bracket/core/mapList.server.ts +++ b/app/features/tournament-bracket/core/mapList.server.ts @@ -1,7 +1,7 @@ import type { Tables, TournamentRoundMaps } from "~/db/tables"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; -import { modesIncluded } from "~/features/tournament/tournament-utils"; +import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils"; import type { Round } from "~/modules/brackets-model"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator"; @@ -132,7 +132,7 @@ export function resolveFreshTeamPickedMapList( if (count() === 1) { return starterMap({ seed: String(args.matchId), - modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }), + modesIncluded: mapPickingStyleToModes(args.mapPickingStyle), tiebreakerMaps: new MapPool(tieBreakerMapPool), teams: [ { @@ -151,7 +151,7 @@ export function resolveFreshTeamPickedMapList( return createTournamentMapList({ count: count(), seed: String(args.matchId), - modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }), + modesIncluded: mapPickingStyleToModes(args.mapPickingStyle), tiebreakerMaps: new MapPool(tieBreakerMapPool), teams: [ { @@ -173,7 +173,7 @@ export function resolveFreshTeamPickedMapList( return createTournamentMapList({ count: count(), seed: String(args.matchId), - modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }), + modesIncluded: mapPickingStyleToModes(args.mapPickingStyle), tiebreakerMaps: new MapPool(tieBreakerMapPool), teams: [ { diff --git a/app/features/tournament-organization/routes/org.$slug.edit.tsx b/app/features/tournament-organization/routes/org.$slug.edit.tsx index 3ddd82434..21a3d8bf1 100644 --- a/app/features/tournament-organization/routes/org.$slug.edit.tsx +++ b/app/features/tournament-organization/routes/org.$slug.edit.tsx @@ -7,11 +7,11 @@ import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { AddFieldButton } from "~/components/form/AddFieldButton"; import { FormFieldset } from "~/components/form/FormFieldset"; -import { MyForm } from "~/components/form/MyForm"; +import { InputFormField } from "~/components/form/InputFormField"; import { SelectFormField } from "~/components/form/SelectFormField"; +import { SendouForm } from "~/components/form/SendouForm"; import { TextAreaFormField } from "~/components/form/TextAreaFormField"; import { TextArrayFormField } from "~/components/form/TextArrayFormField"; -import { TextFormField } from "~/components/form/TextFormField"; import { ToggleFormField } from "~/components/form/ToggleFormField"; import { UserSearchFormField } from "~/components/form/UserSearchFormField"; import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables"; @@ -44,7 +44,7 @@ export default function TournamentOrganizationEditPage() { return (
      - - label={t("common:forms.name")} name="name" /> + + label={t("common:forms.name")} + name="name" + /> - + label={t("common:forms.description")} name="description" maxLength={TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH} @@ -84,16 +87,16 @@ export default function TournamentOrganizationEditPage() { - + label={t("org:edit.form.socialLinks.title")} name="socials" - defaultFieldValue="" + format="object" /> - +
      ); } @@ -159,7 +162,7 @@ function MemberFieldset({ }))} /> - + label={t("org:edit.form.members.roleDisplayName.title")} name={`members.${idx}.roleDisplayName` as const} /> @@ -213,7 +216,7 @@ function SeriesFieldset({ clearErrors("series"); }} > - + label={t("org:edit.form.series.seriesName.title")} name={`series.${idx}.name` as const} /> diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 18eec0bef..21378d9f5 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -452,6 +452,7 @@ export function forShowcase() { .select((eb) => [ "Tournament.id", "Tournament.settings", + "CalendarEvent.authorId", "CalendarEvent.name", "CalendarEventDate.startTime", eb @@ -538,6 +539,36 @@ function databaseTimestampWeekAgo() { return dateToDatabaseTimestamp(now); } +export function findAllBetweenTwoTimestamps({ + startTime, + endTime, +}: { + startTime: Date; + endTime: Date; +}) { + return db + .selectFrom("CalendarEvent") + .innerJoin( + "CalendarEventDate", + "CalendarEvent.id", + "CalendarEventDate.eventId", + ) + .innerJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id") + .select(["Tournament.id as tournamentId"]) + .where( + "CalendarEventDate.startTime", + ">=", + dateToDatabaseTimestamp(startTime), + ) + .where( + "CalendarEventDate.startTime", + "<=", + dateToDatabaseTimestamp(endTime), + ) + .where("CalendarEvent.hidden", "=", 0) + .execute(); +} + export function topThreeResultsByTournamentId(tournamentId: number) { return db .selectFrom("TournamentResult") diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 93308874c..5095e30aa 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -14,7 +14,6 @@ import * as UserRepository from "~/features/user-page/UserRepository.server"; import { logger } from "~/utils/logger"; import { errorToastIfFalsy, - notFoundIfFalsy, parseFormData, parseParams, uploadImageIfSubmitted, @@ -25,7 +24,6 @@ import { idObject } from "~/utils/zod"; import { checkIn } from "../queries/checkIn.server"; import { deleteTeam } from "../queries/deleteTeam.server"; import deleteTeamMember from "../queries/deleteTeamMember.server"; -import { findByIdentifier } from "../queries/findByIdentifier.server"; import { findOwnTournamentTeam } from "../queries/findOwnTournamentTeam.server"; import { joinTeam } from "../queries/joinLeaveTeam.server"; import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server"; @@ -52,7 +50,6 @@ export const action: ActionFunction = async ({ request, params }) => { schema: idObject, }); const tournament = await tournamentFromDB({ tournamentId, user }); - const event = notFoundIfFalsy(findByIdentifier(tournamentId)); errorToastIfFalsy( !tournament.hasStarted, @@ -198,7 +195,10 @@ export const action: ActionFunction = async ({ request, params }) => { errorToastIfFalsy( validateCounterPickMapPool( mapPool, - isOneModeTournamentOf(event), + isOneModeTournamentOf( + tournament.ctx.mapPickingStyle, + tournament.ctx.toSetMapPool, + ), tournament.ctx.tieBreakerMapPool, ) === "VALID", "Invalid map pool", diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 025927ebf..663627863 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -1,6 +1,7 @@ +import * as R from "remeda"; import { INVITE_CODE_LENGTH } from "~/constants"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; -import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes"; import { weekNumberToDate } from "~/utils/dates"; import { tournamentLogoUrl } from "~/utils/urls"; import type { Tables, TournamentStageSettings } from "../../db/tables"; @@ -13,33 +14,46 @@ import type { TournamentData } from "../tournament-bracket/core/Tournament.serve import type { PlayedSet } from "./core/sets.server"; import { LEAGUES, TOURNAMENT } from "./tournament-constants"; +const mapPickingStyleToModeRecord = { + AUTO_SZ: ["SZ"], + AUTO_TC: ["TC"], + AUTO_RM: ["RM"], + AUTO_CB: ["CB"], + AUTO_ALL: rankedModesShort, +} as const; + +export const mapPickingStyleToModes = ( + mapPickingStyle: Exclude, +) => { + return mapPickingStyleToModeRecord[mapPickingStyle].slice(); +}; + export function modesIncluded( - tournament: Pick, + mapPickingStyle: Tables["Tournament"]["mapPickingStyle"], + toSetMapPool: Array<{ mode: ModeShort }>, ): ModeShort[] { - switch (tournament.mapPickingStyle) { - case "AUTO_SZ": { - return ["SZ"]; - } - case "AUTO_TC": { - return ["TC"]; - } - case "AUTO_RM": { - return ["RM"]; - } - case "AUTO_CB": { - return ["CB"]; - } - default: { - return [...rankedModesShort]; - } + if (mapPickingStyle !== "TO") { + return mapPickingStyleToModes(mapPickingStyle); } + + const pickedModes = R.unique(toSetMapPool.map((map) => map.mode)); + + // fallback + if (pickedModes.length === 0) { + return [...rankedModesShort]; + } + + return pickedModes.sort( + (a, b) => modesShort.indexOf(a) - modesShort.indexOf(b), + ); } export function isOneModeTournamentOf( - tournament: Pick, + mapPickingStyle: Tables["Tournament"]["mapPickingStyle"], + toSetMapPool: Array<{ mode: ModeShort }>, ) { - return modesIncluded(tournament).length === 1 - ? modesIncluded(tournament)[0] + return modesIncluded(mapPickingStyle, toSetMapPool).length === 1 + ? modesIncluded(mapPickingStyle, toSetMapPool)[0] : null; } @@ -370,3 +384,10 @@ export function validateCanJoinTeam({ return "VALID"; } + +export function normalizedTeamCount({ + teamsCount, + minMembersPerTeam, +}: { teamsCount: number; minMembersPerTeam: number }) { + return teamsCount * minMembersPerTeam; +} diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index e87fc5d4a..3475f4d2d 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -256,6 +256,7 @@ function ExtraInfos() { return (
      +
      #{data.user.id}
      {data.user.discordUniqueName && (
      diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx index 8e75fb4f7..4e600e71c 100644 --- a/app/features/vods/routes/vods.new.tsx +++ b/app/features/vods/routes/vods.new.tsx @@ -27,9 +27,9 @@ import { useHasRole } from "~/modules/permissions/hooks"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { Alert } from "../../../components/Alert"; import { DateFormField } from "../../../components/form/DateFormField"; -import { MyForm } from "../../../components/form/MyForm"; +import { InputFormField } from "../../../components/form/InputFormField"; import { SelectFormField } from "../../../components/form/SelectFormField"; -import { TextFormField } from "../../../components/form/TextFormField"; +import { SendouForm } from "../../../components/form/SendouForm"; import { videoMatchTypes } from "../vods-constants"; import { videoInputSchema } from "../vods-schemas"; @@ -58,7 +58,7 @@ export default function NewVodPage() { return (
      - - +
      ); } @@ -96,7 +96,7 @@ function FormFields() { return ( <> - + label={t("vods:forms.title.youtubeUrl")} name="video.youtubeUrl" placeholder="https://www.youtube.com/watch?v=-dQ6JsVIKdY" @@ -104,7 +104,7 @@ function FormFields() { size="medium" /> - + label={t("vods:forms.title.videoTitle")} name="video.title" placeholder="[SCL 47] (Grand Finals) Team Olive vs. Kraken Paradise" @@ -278,7 +278,7 @@ function MatchesFieldset({ {canRemove ? remove(idx)} /> : null}
      - + required label={t("vods:forms.title.startTimestamp")} name={`video.matches.${idx}.startsAt`} diff --git a/app/modules/in-game-lists/games.ts b/app/modules/in-game-lists/games.ts new file mode 100644 index 000000000..b5cd3cb39 --- /dev/null +++ b/app/modules/in-game-lists/games.ts @@ -0,0 +1,2 @@ +export const gamesShort = ["S1", "S2", "S3"] as const; +export const versusShort = ["1v1", "2v2", "3v3", "4v4"] as const; diff --git a/app/modules/in-game-lists/index.ts b/app/modules/in-game-lists/index.ts index 73cb9d8a4..37bb0b53d 100644 --- a/app/modules/in-game-lists/index.ts +++ b/app/modules/in-game-lists/index.ts @@ -1,4 +1,4 @@ -export { modes, modesShort } from "./modes"; +export { modesShort } from "./modes"; export { weaponCategories, mainWeaponIds, diff --git a/app/modules/in-game-lists/modes.ts b/app/modules/in-game-lists/modes.ts index aadd7c80f..bbe52469d 100644 --- a/app/modules/in-game-lists/modes.ts +++ b/app/modules/in-game-lists/modes.ts @@ -1,12 +1,6 @@ import type { RankedModeShort } from "./types"; -export const modes = [ - { short: "TW" }, - { short: "SZ" }, - { short: "TC" }, - { short: "RM" }, - { short: "CB" }, -] as const; - -export const modesShort = modes.map((mode) => mode.short); +export const modesShort = ["TW", "SZ", "TC", "RM", "CB"] as const; export const rankedModesShort = modesShort.slice(1) as RankedModeShort[]; + +export const modesShortWithSpecial = [...modesShort, "TB", "SR"] as const; diff --git a/app/modules/in-game-lists/types.ts b/app/modules/in-game-lists/types.ts index a68d48505..cefd47a4b 100644 --- a/app/modules/in-game-lists/types.ts +++ b/app/modules/in-game-lists/types.ts @@ -1,5 +1,5 @@ import type { abilities } from "./abilities"; -import type { modes } from "./modes"; +import type { modesShort, modesShortWithSpecial } from "./modes"; import type { stageIds } from "./stage-ids"; import type { mainWeaponIds, @@ -7,8 +7,9 @@ import type { subWeaponIds, } from "./weapon-ids"; -export type ModeShort = (typeof modes)[number]["short"]; -export type RankedModeShort = "SZ" | "TC" | "RM" | "CB"; +export type ModeShort = (typeof modesShort)[number]; +export type ModeShortWithSpecial = (typeof modesShortWithSpecial)[number]; +export type RankedModeShort = Exclude; export type StageId = (typeof stageIds)[number]; diff --git a/app/routines/notifyCheckInStart.ts b/app/routines/notifyCheckInStart.ts index 32ce018b5..6034d67d6 100644 --- a/app/routines/notifyCheckInStart.ts +++ b/app/routines/notifyCheckInStart.ts @@ -1,6 +1,6 @@ -import * as CalendarRepository from "../features/calendar/CalendarRepository.server"; import { notify } from "../features/notifications/core/notify.server"; import { tournamentDataCached } from "../features/tournament-bracket/core/Tournament.server"; +import * as TournamentRepository from "../features/tournament/TournamentRepository.server"; import { logger } from "../utils/logger"; import { Routine } from "./routine.server"; @@ -9,10 +9,9 @@ export const NotifyCheckInStartRoutine = new Routine({ func: async () => { const now = new Date(); const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000); - const tournaments = await CalendarRepository.findAllBetweenTwoTimestamps({ + const tournaments = await TournamentRepository.findAllBetweenTwoTimestamps({ startTime: now, endTime: oneHourFromNow, - onlyTournaments: true, }); for (const { tournamentId } of tournaments) { diff --git a/app/styles/calendar.css b/app/styles/calendar.css deleted file mode 100644 index a38c7e635..000000000 --- a/app/styles/calendar.css +++ /dev/null @@ -1,191 +0,0 @@ -.calendar__placeholder { - height: 48rem; -} - -.calendar__weeks { - display: flex; - align-items: center; - justify-content: center; - gap: var(--s-2); - overflow-x: hidden; - padding-inline: var(--s-2-5); - - --full-size-week-height: 6.75rem; - --full-size-week-width: 5rem; -} - -.calendar__week { - display: flex; - min-width: calc(var(--full-size-week-width) - 2rem); - height: auto; - min-height: calc(var(--full-size-week-height) - 2rem); - flex-direction: column; - justify-content: space-between; - padding: var(--s-1-5); - border-radius: var(--rounded); - background-color: var(--theme-very-transparent); - cursor: pointer; - font-size: var(--fonts-xxxs); - font-weight: var(--bold); - text-align: center; -} - -.calendar__week:hover { - background-color: var(--theme-transparent); -} - -.calendar__week:focus { - outline: 2px solid var(--theme); -} - -.calendar__event-count { - font-size: var(--fonts-xxxs); - font-weight: var(--body); -} - -.calendar__week:nth-child(1), -.calendar__week:nth-child(2), -.calendar__week:nth-child(8), -.calendar__week:nth-child(9) { - min-width: calc(var(--full-size-week-width) - 2.5rem); - height: calc(var(--full-size-week-height) - 2.5rem); - cursor: initial; - font-size: var(--fonts-xxxxs); - opacity: 0; -} - -.calendar__week:nth-child(5) { - min-width: var(--full-size-week-width); - min-height: var(--full-size-week-height); - color: var(--text); - font-size: var(--fonts-sm); -} - -.calendar__week:nth-child(5) > .calendar__event-count { - font-size: var(--fonts-sm); -} - -.calendar__week:nth-child(4), -.calendar__week:nth-child(6) { - min-width: calc(var(--full-size-week-width) - 1rem); - min-height: calc(var(--full-size-week-height) - 1rem); - font-size: var(--fonts-xs); -} - -.calendar__week:nth-child(4) > .calendar__event-count, -.calendar__week:nth-child(6) > .calendar__event-count { - font-size: var(--fonts-xxs); -} - -.calendar__week__relative { - max-width: 3rem; - margin: 0 auto; - text-align: center; - white-space: pre-line; -} - -.calendar__week__dash { - line-height: 0.8; -} - -.calendar__events-to-report { - line-height: 1.25rem; -} - -.calendar__events-container { - background-color: var(--bg-lighter); -} - -.calendar__event { - display: flex; - flex-direction: column; - padding-top: var(--s-3); - font-size: var(--fonts-lg); - padding-inline: var(--s-4); -} - -.calendar__event + .calendar__event { - border-top: 2px solid var(--divider); -} - -.calendar__event:last-child { - padding-block-end: var(--s-4); -} - -.calendar__event__date-container { - background-color: var(--bg); -} - -.calendar__event__divider { - padding-block-start: 40px; -} - -.calendar__event__date { - font-weight: var(--semi-bold); - padding-block-start: var(--s-6); -} - -.calendar__event__time { - font-weight: var(--semi-bold); -} - -.calendar__event__top-info-container { - display: flex; - height: 1.25rem; - align-items: center; - font-size: var(--fonts-sm); - gap: var(--s-2); -} - -.calendar__event__author { - color: var(--text-lighter); -} - -.calendar__event__title { - color: var(--text); - font-size: var(--fonts-xl); - line-height: 1.35; -} - -.calendar__event__day { - color: var(--text-lighter); -} - -.calendar__event__participant-counts { - display: flex; - align-items: center; - color: var(--text-lighter); - font-size: var(--fonts-xs); - gap: var(--s-2); - margin-block-end: var(--s-1); -} - -.calendar__event__participant-counts > svg { - width: 1rem; - margin-block-end: 1px; -} - -.calendar__event__bottom-info-container { - display: flex; - align-items: center; - font-size: var(--fonts-sm); - gap: var(--s-2); -} - -.calendar__no-events { - color: var(--text-lighter); - padding-block: var(--s-16); - text-align: center; -} - -.calendar__time-zone-info { - color: var(--text-lighter); - font-size: var(--fonts-xs); - padding-block-end: var(--s-2); - text-align: center; -} - -.calendar__event-logo { - border-radius: 100%; - min-width: 40px; -} diff --git a/app/styles/common.css b/app/styles/common.css index 0b05d42f4..394776eae 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -712,6 +712,18 @@ abbr[title] { list-style: none; } +.calendar__event__tags.small { + font-size: var(--fonts-xxxs); +} + +.calendar__event__tags.centered { + justify-content: center; +} + +.calendar__event__tags.small > li { + padding: 0 var(--s-1); +} + .calendar__event__tags > li { display: flex; align-items: center; @@ -724,20 +736,6 @@ abbr[title] { color: var(--black-text); } -.calendar__event__badge-tag { - color: var(--badge-text); -} - -.calendar__event__ranked-tag { - background-color: var(--theme-info-transparent); - color: var(--theme-info); -} - -.calendar__event__unranked-tag { - background-color: var(--theme-success-transparent); - color: var(--theme-success); -} - .calendar__event__tag-delete-button { margin-left: auto; } diff --git a/app/styles/front.css b/app/styles/front.css index 88be782c4..ed41cad90 100644 --- a/app/styles/front.css +++ b/app/styles/front.css @@ -195,114 +195,6 @@ background-color: var(--bg-lightest); } -.front__tournament-card__container { - min-width: var(--card-width); - max-width: var(--card-width); - height: calc(var(--card-height) + 22px); - display: flex; - flex-direction: column; - gap: var(--s-1); -} - -.front__tournament-card__container__tall { - height: 100%; -} - -.front__tournament-card { - background-color: var(--bg-lightest); - border-radius: var(--rounded); - color: var(--text); - padding: var(--s-1) var(--s-2) var(--s-2) var(--s-2); - height: 100%; - min-width: var(--card-width); - max-width: var(--card-width); -} - -.front__tournament-card:hover .front__tournament-card__tournament-avatar-img { - outline: 6px solid var(--theme-transparent); -} - -.front__tournament-card__img-container { - background-color: var(--bg); - padding: var(--s-1-5); - border-radius: 100%; - margin-left: -10px; - margin-top: -10px; -} - -.front__tournament-card__tournament-avatar-img { - border-radius: 100%; -} - -.front__tournament-card__org { - display: flex; - gap: var(--s-1); - font-size: var(--fonts-xxs); - color: var(--text-lighter); -} - -.front__tournament-card__time { - font-size: var(--fonts-xs); - font-weight: var(--semi-bold); - color: var(--text-lighter); - display: block; -} - -.front__tournament-card__name { - text-align: center; - font-weight: var(--semi-bold); - font-size: var(--fonts-sm); - white-space: nowrap; - text-overflow: ellipsis; - max-width: 225px; - overflow: hidden; -} - -.front__tournament-card__team-count { - font-size: var(--fonts-xxs); - font-weight: var(--bold); - background-color: var(--bg-lightest); - border-radius: var(--rounded-sm); - width: max-content; - padding: var(--s-0-5) var(--s-1-5); - display: flex; - gap: var(--s-1); -} - -.front__tournament-card__team-count svg { - width: 12px; -} - -.front__tournament-card__tag { - font-size: var(--fonts-xxs); - font-weight: var(--bold); - border-radius: var(--rounded-sm); - padding: var(--s-0-5) var(--s-1-5); -} - -.front__tournament-card__ranked { - background-color: var(--theme-info-transparent); - color: var(--theme-info); -} - -.front__tournament-card__unranked { - background-color: var(--theme-success-transparent); - color: var(--theme-success); -} - -.front__tournament-card__first-placers { - margin-inline: auto; - margin-top: var(--s-5); - width: max-content; -} - -.front__tournament-card__first-placers__team-name { - max-width: 150px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .front__result-highlights { display: flex; gap: var(--s-2); diff --git a/app/styles/layout.css b/app/styles/layout.css index e7c055a59..c17318d0f 100644 --- a/app/styles/layout.css +++ b/app/styles/layout.css @@ -402,7 +402,6 @@ .layout__overlay-nav__dialog { padding-block: var(--s-12) !important; - padding-block-end: var(--s-32) !important; padding-inline: 0 !important; } diff --git a/app/utils/dates.ts b/app/utils/dates.ts index 020d4b555..94fdab6a7 100644 --- a/app/utils/dates.ts +++ b/app/utils/dates.ts @@ -1,9 +1,14 @@ +import { parseDate } from "@internationalized/date"; import { getWeek } from "date-fns"; import type { MonthYear } from "~/features/plus-voting/core"; import type { DayMonthYear } from "./zod"; export function databaseTimestampToDate(timestamp: number) { - return new Date(timestamp * 1000); + return new Date(databaseTimestampToJavascriptTimestamp(timestamp)); +} + +export function databaseTimestampToJavascriptTimestamp(timestamp: number) { + return timestamp * 1000; } export function dateToDatabaseTimestamp(date: Date) { @@ -21,6 +26,15 @@ export function dayMonthYearToDate({ day, month, year }: DayMonthYear) { return new Date(Date.UTC(year, month, day, 12)); } +/** + * Converts a date represented by day, month, and year into a DateValue object (used by react-aria-components), noon UTC. + */ +export function dayMonthYearToDateValue({ day, month, year }: DayMonthYear) { + const isoString = dateToYYYYMMDD(new Date(Date.UTC(year, month, day, 12))); + + return parseDate(isoString); +} + /** * Converts a date represented by day, month, and year into a database timestamp, noon UTC. */ diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts index 77a0cece1..1ae461d59 100644 --- a/app/utils/playwright.ts +++ b/app/utils/playwright.ts @@ -59,6 +59,11 @@ export async function selectComboboxValue({ /** page.goto that waits for the page to be hydrated before proceeding */ export async function navigate({ page, url }: { page: Page; url: string }) { await page.goto(url); + await expectIsHydrated(page); +} + +/** Waits and expects the page to be hydrated (click handlers etc. ready for testing) */ +export async function expectIsHydrated(page: Page) { await expect(page.getByTestId("hydrated")).toHaveCount(1); } diff --git a/app/utils/types.ts b/app/utils/types.ts index 974f5cab1..3cab1748d 100644 --- a/app/utils/types.ts +++ b/app/utils/types.ts @@ -1,3 +1,11 @@ +/** + * Asserts that a code path is unreachable by accepting a value of type `never`. + * This function is useful for exhaustive checks in switch statements or discriminated unions. + * If called, it throws an error with a message containing the unexpected value. + * + * @param x - The value that should never occur (of type `never`). + * @throws {Error} Throws an error indicating an unexpected value was encountered. + */ export function assertUnreachable(x: never): never { throw new Error( `Didn't expect to get here. Unexpected value: ${JSON.stringify(x)}`, diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 36c0541d3..f7a173c1b 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -3,6 +3,7 @@ import type { GearType, Preference, Tables } from "~/db/tables"; import type { ArtSource } from "~/features/art/art-types"; import type { AuthErrorCode } from "~/features/auth/core/errors"; import { serializeBuild } from "~/features/build-analyzer"; +import type { CalendarFilters } from "~/features/calendar/calendar-types"; import type { StageBackgroundStyle } from "~/features/map-planner"; import type { TierName } from "~/features/mmr/mmr-constants"; import { JOIN_CODE_SEARCH_PARAM_KEY } from "~/features/sendouq/q-constants"; @@ -12,10 +13,12 @@ import type { AbilityWithUnknown, BuildAbilitiesTupleWithUnknown, MainWeaponId, + ModeShortWithSpecial, SpecialWeaponId, StageId, SubWeaponId, } from "~/modules/in-game-lists/types"; +import type { DayMonthYear } from "~/utils/zod"; const staticAssetsUrl = ({ folder, @@ -262,6 +265,31 @@ export const weaponBuildStatsPage = (weaponSlug: string) => export const weaponBuildPopularPage = (weaponSlug: string) => `${weaponBuildPage(weaponSlug)}/popular`; +export const calendarPage = (args?: { + filters?: CalendarFilters; + dayMonthYear?: DayMonthYear; +}) => { + const params = new URLSearchParams(); + if (args?.filters) { + params.set("filters", JSON.stringify(args.filters)); + } + if (args?.dayMonthYear) { + params.set("day", String(args.dayMonthYear.day)); + params.set("month", String(args.dayMonthYear.month)); + params.set("year", String(args.dayMonthYear.year)); + } + + return `${CALENDAR_PAGE}${params.toString() ? `?${params.toString()}` : ""}`; +}; + +export const calendarIcalFeed = (filters?: CalendarFilters) => { + const params = new URLSearchParams(); + if (filters) { + params.set("filters", JSON.stringify(filters)); + } + return `${SENDOU_INK_BASE_URL}/calendar.ics${params.toString() ? `?${params.toString()}` : ""}`; +}; + export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`; export const calendarEditPage = (eventId?: number) => `/calendar/new${eventId ? `?eventId=${eventId}` : ""}`; @@ -452,7 +480,7 @@ export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) => `/static-assets/img/special-weapons/${specialWeaponSplId}`; export const abilityImageUrl = (ability: AbilityWithUnknown) => `/static-assets/img/abilities/${ability}`; -export const modeImageUrl = (mode: ModeShort) => +export const modeImageUrl = (mode: ModeShortWithSpecial) => `/static-assets/img/modes/${mode}`; export const stageImageUrl = (stageId: StageId) => `/static-assets/img/stages/${stageId}`; diff --git a/app/utils/zod.ts b/app/utils/zod.ts index f7471a9bc..af9aaaff2 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -106,6 +106,17 @@ export const qWeapon = z.object({ }); export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]); +export const modeShortWithSpecial = z.enum([ + "TW", + "SZ", + "TC", + "RM", + "CB", + "SR", + "TB", +]); + +export const gamesShortSchema = z.enum(["S1", "S2", "S3"]); export const stageId = z.preprocess(actualNumber, numericEnum(stageIds)); @@ -334,9 +345,9 @@ export function numericEnum( } export const dayMonthYear = z.object({ - day: z.number().int().min(1).max(31), - month: z.number().int().min(0).max(11), - year: z.number().int().min(2015).max(2100), + day: z.coerce.number().int().min(1).max(31), + month: z.coerce.number().int().min(0).max(11), + year: z.coerce.number().int().min(2015).max(2100), }); export type DayMonthYear = z.infer; diff --git a/e2e/badges.spec.ts b/e2e/badges.spec.ts index f41261e3e..cd26af515 100644 --- a/e2e/badges.spec.ts +++ b/e2e/badges.spec.ts @@ -34,7 +34,7 @@ test.describe("Badges", () => { await expect(page).toHaveURL(badgePage(1)); await page.getByTestId("notifications-button").click(); - await page.getByText("See all").click(); + await page.getByTestId("notifications-see-all-button").click(); await expect( page.getByRole("heading", { name: "Notifications" }), diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts new file mode 100644 index 000000000..930936a05 --- /dev/null +++ b/e2e/calendar.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from "@playwright/test"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { + expectIsHydrated, + impersonate, + isNotVisible, + navigate, + seed, +} from "~/utils/playwright"; +import { calendarPage } from "~/utils/urls"; + +const SENDOU_INK_TOURNAMENTS_COUNT = 6; + +test.describe("Calendar", () => { + test("applies filters and operates hidden events toggle", async ({ + page, + }) => { + await seed(page); + await navigate({ + page, + url: calendarPage(), + }); + + await page.getByTestId("filter-events-button").click(); + await page.getByText("Only events hosted on sendou.ink").click(); + + await page.getByText("Apply", { exact: true }).click(); + + const tournamentCardLocator = page.getByTestId("tournament-card"); + const hiddenEventsToggleButtonLocator = page.getByTestId( + "hidden-events-button", + ); + + await expect(tournamentCardLocator).toHaveCount( + SENDOU_INK_TOURNAMENTS_COUNT, + ); + + await page.reload(); + await expectIsHydrated(page); + + // remembers selection via search params + await expect(tournamentCardLocator).toHaveCount( + SENDOU_INK_TOURNAMENTS_COUNT, + ); + + await hiddenEventsToggleButtonLocator.first().click(); + + await expect + .poll(() => tournamentCardLocator.count()) + .toBeGreaterThan(SENDOU_INK_TOURNAMENTS_COUNT); + + const countAfterToggle = await tournamentCardLocator.count(); + + await hiddenEventsToggleButtonLocator.first().click(); + await expect + .poll(() => tournamentCardLocator.count()) + .toBeLessThan(countAfterToggle); // not SENDOU_INK_TOURNAMENTS_COUNT as it's possible we untoggle more than one tournament + }); + + test("sets default filters", async ({ page }) => { + await seed(page); + await impersonate(page, NZAP_TEST_ID); + await navigate({ + page, + url: calendarPage(), + }); + + const hiddenEventsToggleButtonLocator = page.getByTestId( + "hidden-events-button", + ); + + await isNotVisible(hiddenEventsToggleButtonLocator); + + await page.getByTestId("filter-events-button").click(); + await page.getByText("Only ranked events").click(); + + await page.getByText("Apply & make default", { exact: true }).click(); + + await expect(hiddenEventsToggleButtonLocator.first()).toBeVisible(); + + await navigate({ + page, + url: calendarPage(), + }); + + // remembers selection via user preferences + await expect(hiddenEventsToggleButtonLocator.first()).toBeVisible(); + }); + + test("navigates view more buttons", async ({ page }) => { + await seed(page); + await navigate({ + page, + url: calendarPage(), + }); + + await page.getByTestId("calendar-navigate-button").first().click(); + + await isNotVisible(page.getByTestId("today-header")); + + await page.getByTestId("calendar-navigate-button").nth(1).click(); + await expect(page.getByTestId("today-header")).toBeVisible(); + }); +}); diff --git a/locales/da/calendar.json b/locales/da/calendar.json index a55160032..369f35e4e 100644 --- a/locales/da/calendar.json +++ b/locales/da/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "Discordserver-invitations-URL", "forms.tags": "Tags", "forms.tags.placeholder": "Vælg et tag", - "forms.tags.info": "\"Præmiemærker\" tag tilføjes automatisk, hvis den er anvendelig", "forms.badges": "Præmiemærker", "forms.badges.placeholder": "Vælg et premiemærke", "forms.mapPool": "Banepulje", diff --git a/locales/da/common.json b/locales/da/common.json index 38fb5faa4..04772bc1a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -92,7 +92,6 @@ "errors.genericReload": "Noget gik galt. Prøv at genindlæse siden.", - "tag.name.BADGE": "Premiemærker", "tag.name.SPECIAL": "Særregler", "tag.name.ART": "Kunstpræmier", "tag.name.MONEY": "Pengepræmier", diff --git a/locales/de/calendar.json b/locales/de/calendar.json index 61f738235..6d5c91823 100644 --- a/locales/de/calendar.json +++ b/locales/de/calendar.json @@ -17,7 +17,6 @@ "forms.discordInvite": "Discord-Server Einladungs-URL", "forms.tags": "Tags", "forms.tags.placeholder": "Wähle einen Tag", - "forms.tags.info": "\"Abzeichen-Preis\"-Tag wird automatisch hinzugefügt (falls anwendbar)", "forms.badges": "Abzeichen-Preis", "forms.badges.placeholder": "Wähle ein Abzeichen für das Event", "forms.mapPool": "Arenen-Pool", diff --git a/locales/de/common.json b/locales/de/common.json index a4e367e78..ca139a546 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -89,7 +89,6 @@ "errors.genericReload": "Etwas ist schiefgegangen. Versuche die Seite neu zu laden.", - "tag.name.BADGE": "Abzeichen-Preise", "tag.name.SPECIAL": "Spezielle Regeln", "tag.name.ART": "Kunst-Preise", "tag.name.MONEY": "Geld-Preise", diff --git a/locales/en/calendar.json b/locales/en/calendar.json index 5b68e9b2e..4965d753a 100644 --- a/locales/en/calendar.json +++ b/locales/en/calendar.json @@ -1,8 +1,5 @@ { "inYourTimeZone": "Times in your local time zone:", - "addNew": "Add new", - "noEvents": "No events for the selected week", - "reportResults": "You can report results:", "day": "Day {{number}}", "actions.reportWinners": "Report winners", "actions.delete": "Delete event", @@ -12,8 +9,6 @@ "members": "Members", "results": "Results", "createMapList": "Create map list", - "from": "From {{author}}", - "pastEvents.dividerText": "Past events", "count.teams_one": "{{count}} team", "count.players_one": "{{count}} player", "count.teams_other": "{{count}} teams", @@ -24,7 +19,6 @@ "forms.discordInvite": "Discord server invite URL", "forms.tags": "Tags", "forms.tags.placeholder": "Choose a tag", - "forms.tags.info": "\"Badge prizes\" tag is added automatically if applicable", "forms.badges": "Badge prizes", "forms.badges.placeholder": "Choose a badge prize", "forms.mapPool": "Map pool", @@ -47,11 +41,6 @@ "forms.errors.duplicatePlayer": "Can't have the same player twice in the same team.", "forms.errors.emptyTeam": "Each team must have at least one player.", - "week.this": "This Week", - "week.next": "Next Week", - "week.last": "Last Week", - - "tag.desc.BADGE": "Winner of this event gets a sendou.ink badge.", "tag.desc.SPECIAL": "Ruleset that differs from the standard e.g. limited what weapons can be used.", "tag.desc.ART": "You can win art by playing in this tournament.", "tag.desc.MONEY": "You can win money by playing in this tournament.", @@ -66,7 +55,34 @@ "tag.desc.S2": "The game played is Splatoon 2.", "tag.desc.SR": "Salmon Run event.", "tag.desc.CARDS": "Tableturf Battle event.", - "tag.filter.label": "Filter by tags", - "tournament.filter.label": "Hosted on sendou.ink" + "icalFeed": "iCal feed", + + "filter.button": "Filter", + "filter.heading": "Filter calendar events", + "filter.modes": "Modes", + "filter.exactModes": "Exact modes", + "filter.exactModesBottom": "Only show events that match all selected modes", + "filter.games": "Games", + "filter.vs": "Vs.", + "filter.vs.4v4": "4v4", + "filter.vs.3v3": "3v3", + "filter.vs.2v2": "2v2", + "filter.vs.1v1": "1v1", + "filter.startTime": "Start time", + "filter.startTime.any": "Any", + "filter.startTime.eu": "Europe friendly", + "filter.startTime.na": "Americas friendly", + "filter.startTime.au": "AU/NZ friendly", + "filter.tagsIncluded": "Tags included", + "filter.tagsExcluded": "Tags excluded", + "filter.isSendou": "Only events hosted on sendou.ink", + "filter.isRanked": "Only ranked events", + "filter.minTeamCount": "Minimum team count", + "filter.orgsIncluded": "Visible organizations", + "filter.orgsExcluded": "Hidden organizations", + "filter.authorIdsExcluded": "Authors excluded", + "filter.authorIdsExcludedBottom": "You can find a user's id on their profile page", + "filter.apply": "Apply", + "filter.applyAndDefault": "Apply & make default" } diff --git a/locales/en/common.json b/locales/en/common.json index 475021ae8..bb2fc5776 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -123,6 +123,8 @@ "actions.enable": "Enable", "actions.disable": "Disable", "actions.accept": "Accept", + "actions.next": "Next", + "actions.previous": "Previous", "noResults": "No results", @@ -165,7 +167,6 @@ "errors.genericReload": "Something went wrong. Try reloading the page.", - "tag.name.BADGE": "Badge prizes", "tag.name.SPECIAL": "Special rules", "tag.name.ART": "Art prizes", "tag.name.MONEY": "Money prizes", diff --git a/locales/en/game-misc.json b/locales/en/game-misc.json index c50bf48b0..9daa53ec5 100644 --- a/locales/en/game-misc.json +++ b/locales/en/game-misc.json @@ -58,5 +58,10 @@ "MODE_LONG_SZ": "Splat Zones", "MODE_LONG_TC": "Tower Control", "MODE_LONG_RM": "Rainmaker", - "MODE_LONG_CB": "Clam Blitz" + "MODE_LONG_CB": "Clam Blitz", + "MODE_LONG_SR": "Salmon Run", + "MODE_LONG_TB": "Tableturf Battle", + "GAME_S1": "Splatoon 1", + "GAME_S2": "Splatoon 2", + "GAME_S3": "Splatoon 3" } diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json index 7313e6d5a..76df06d40 100644 --- a/locales/es-ES/calendar.json +++ b/locales/es-ES/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "Enlace de invitación a Discord", "forms.tags": "Etiquetas", "forms.tags.placeholder": "Elegir etiquetas", - "forms.tags.info": "Etiqueta \"Premios de insignia\" se agrega automáticamente si corresponde.", "forms.badges": "Premios de insignia", "forms.badges.placeholder": "Elige un premio de insignia", "forms.mapPool": "Grupo de mapas", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 4d4d59238..58f588cbe 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -98,7 +98,6 @@ "errors.genericReload": "Algo salió mal. Intente recargar la página.", - "tag.name.BADGE": "Premios de insignia", "tag.name.SPECIAL": "Reglas especiales", "tag.name.ART": "Premios de arte", "tag.name.MONEY": "Premios de dinero", diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json index 7313e6d5a..76df06d40 100644 --- a/locales/es-US/calendar.json +++ b/locales/es-US/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "Enlace de invitación a Discord", "forms.tags": "Etiquetas", "forms.tags.placeholder": "Elegir etiquetas", - "forms.tags.info": "Etiqueta \"Premios de insignia\" se agrega automáticamente si corresponde.", "forms.badges": "Premios de insignia", "forms.badges.placeholder": "Elige un premio de insignia", "forms.mapPool": "Grupo de mapas", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 5dd59652d..9b3b2740e 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -104,7 +104,6 @@ "errors.genericReload": "Algo salió mal. Intente recargar la página.", - "tag.name.BADGE": "Premios de insignia", "tag.name.SPECIAL": "Reglas especiales", "tag.name.ART": "Premios de arte", "tag.name.MONEY": "Premios de dinero", diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json index a4ffbbe08..3d5b08b5b 100644 --- a/locales/fr-CA/calendar.json +++ b/locales/fr-CA/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "Lien d'invitation du Discord", "forms.tags": "Tags", "forms.tags.placeholder": "Choisir un tag", - "forms.tags.info": "Le tag \"Badge à gagner\" est ajouté automatiquement si le tournoi est éligible", "forms.badges": "Badge à gagner", "forms.badges.placeholder": "Choisissez le badge à faire gagner", "forms.mapPool": "Stages disponibles", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index bec7e9047..c99bbb79e 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -95,7 +95,6 @@ "errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.", - "tag.name.BADGE": "Badge à gagner", "tag.name.SPECIAL": "Règles spéciales", "tag.name.ART": "Illustration à gagner", "tag.name.MONEY": "Argent à gagner", diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json index a2f52d966..106df6527 100644 --- a/locales/fr-EU/calendar.json +++ b/locales/fr-EU/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "Lien d'invitation du Discord", "forms.tags": "Tags", "forms.tags.placeholder": "Choisir un tag", - "forms.tags.info": "Le tag \"Badge à gagner\" est ajouté automatiquement si le tournoi est éligible", "forms.badges": "Badge à gagner", "forms.badges.placeholder": "Choisissez le badge à faire gagner", "forms.mapPool": "Stages disponibles", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index bf3f50e93..9b00f0238 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -165,7 +165,6 @@ "errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.", - "tag.name.BADGE": "Badge à gagner", "tag.name.SPECIAL": "Règles spéciales", "tag.name.ART": "Illustration à gagner", "tag.name.MONEY": "Argent à gagner", diff --git a/locales/he/calendar.json b/locales/he/calendar.json index c158c94f8..f17ae0a45 100644 --- a/locales/he/calendar.json +++ b/locales/he/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "כתובת URL הזמנה לשרת Discord", "forms.tags": "תגים", "forms.tags.placeholder": "בחירת תג", - "forms.tags.info": "\"Badge prizes\" תג נוסף אוטומטית אם רלוונטי", "forms.badges": "פרסי תגים", "forms.badges.placeholder": "בחירת פרס תג", "forms.mapPool": "מאגר מפות", diff --git a/locales/he/common.json b/locales/he/common.json index d5838a443..eab78bba2 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -95,7 +95,6 @@ "errors.genericReload": "משהו השתבש. נסו לטעון מחדש את העמוד.", - "tag.name.BADGE": "פרסי תג", "tag.name.SPECIAL": "חוקים מיוחדים", "tag.name.ART": "פרסי ציור", "tag.name.MONEY": "פרסים בכסף", diff --git a/locales/it/calendar.json b/locales/it/calendar.json index 5e0b76943..b273e262b 100644 --- a/locales/it/calendar.json +++ b/locales/it/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "URL invito server Discord", "forms.tags": "Tag", "forms.tags.placeholder": "Scegli un tag", - "forms.tags.info": "Il tag \"Badge prizes\" è aggiunto automaticamente se necessario", "forms.badges": "Medaglie in palio", "forms.badges.placeholder": "Scegli una medaglia come premio", "forms.mapPool": "Pool di scenari", diff --git a/locales/it/common.json b/locales/it/common.json index f0b6a09db..82ba4fe0f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -151,7 +151,6 @@ "errors.genericReload": "Qualcosa è andato storto. Prova a ricaricare la pagina.", - "tag.name.BADGE": "Premi medaglie", "tag.name.SPECIAL": "Regole speciali", "tag.name.ART": "Premi artistici", "tag.name.MONEY": "Premi monetari", diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json index a2d58fb28..b5c94de99 100644 --- a/locales/ja/calendar.json +++ b/locales/ja/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "Discord サーバー招待 URL", "forms.tags": "タグ", "forms.tags.placeholder": "タグを選択", - "forms.tags.info": "\"バッジプライズ\" タグは可能な場合に自動的に適用されます", "forms.badges": "バッジプライズ", "forms.badges.placeholder": "バッジプライズを選択する", "forms.mapPool": "ステージプール", diff --git a/locales/ja/common.json b/locales/ja/common.json index bf2f8a19c..723032dbe 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -115,7 +115,6 @@ "errors.genericReload": "エラーが発生しました。ページを再読込してください。", - "tag.name.BADGE": "バッジプライズ", "tag.name.SPECIAL": "特別ルール", "tag.name.ART": "イラスト賞品", "tag.name.MONEY": "賞金", diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json index 2a4d6befb..dcf64883c 100644 --- a/locales/ko/calendar.json +++ b/locales/ko/calendar.json @@ -13,7 +13,6 @@ "forms.discordInvite": "디스코드 서버 초대 주소", "forms.tags": "태그", "forms.tags.placeholder": "태그를 선택하세요", - "forms.tags.info": "\"배지 상품\" 태그는 적용 가능하면 자동으로 추가됩니다", "forms.badges": "배지 상품", "forms.badges.placeholder": "배지 상품을 선택하세요", "forms.participantCount": "참여자 수", diff --git a/locales/ko/common.json b/locales/ko/common.json index 92bfe9cc1..4d2e34eb0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -94,7 +94,6 @@ "errors.genericReload": "뭔가 잘못됐습니다 페이지를 새로고침해보세요.", - "tag.name.BADGE": "배지 상품", "tag.name.SPECIAL": "특별 규칙", "tag.name.ART": "그림 상품", "tag.name.MONEY": "상금", diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json index 283d66500..b6bb559c4 100644 --- a/locales/nl/calendar.json +++ b/locales/nl/calendar.json @@ -17,7 +17,6 @@ "forms.discordInvite": "Discord server uitnodiging URL", "forms.tags": "Tags", "forms.tags.placeholder": "Kies een tag", - "forms.tags.info": "De \"Badge prizes\" tag wordt indien mogelijk automatisch toegevoegd", "forms.badges": "Badge prijzen", "forms.badges.placeholder": "Kies een badge prijs", "forms.mapPool": "Beschikbare levels", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3c72da104..b6d8e31b2 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -39,7 +39,6 @@ "forms.description": "Beschrijving", "forms.errors.title": "De volgende errors moeten nog worden opgelost", - "tag.name.BADGE": "Badge prijzen", "tag.name.SPECIAL": "Speciale spelregels", "tag.name.ART": "Kunst prijzen", "tag.name.MONEY": "Geld prijzen", diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json index 45a2b0478..780547196 100644 --- a/locales/pl/calendar.json +++ b/locales/pl/calendar.json @@ -17,7 +17,6 @@ "forms.discordInvite": "URL do servera Discord", "forms.tags": "Tagi", "forms.tags.placeholder": "Wybierz tag", - "forms.tags.info": "Tag \"Odznaki\" dodawany automatycznie jeśli możliwe", "forms.badges": "Odznaki", "forms.badges.placeholder": "Wybierz odznakę", "forms.mapPool": "Pula map", diff --git a/locales/pl/common.json b/locales/pl/common.json index cd4ed8330..a1e76e8e7 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -88,7 +88,6 @@ "errors.genericReload": "Coś poszło nie tak. Spróbuj ponownie załadować stronę.", - "tag.name.BADGE": "Oznaki", "tag.name.SPECIAL": "Zasady specjalne", "tag.name.ART": "Rysunki", "tag.name.MONEY": "Nagrody pieniężne", diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json index 23350ba2d..fb741d223 100644 --- a/locales/pt-BR/calendar.json +++ b/locales/pt-BR/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "URL de convite do servidor do Discord", "forms.tags": "Marcações", "forms.tags.placeholder": "Escolha uma marcação", - "forms.tags.info": "\"Prêmio(s) de insígnia\" é adicionado automaticamente como marcação se aplicável", "forms.badges": "Prêmio(s) de insígnia", "forms.badges.placeholder": "Escolha um prêmio de insígnia", "forms.mapPool": "Conjunto de mapas", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 27a5da663..2a490b285 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -104,7 +104,6 @@ "errors.genericReload": "Algo deu errado. Tente recarregar a página.", - "tag.name.BADGE": "Prêmio(s) em insígnia", "tag.name.SPECIAL": "Regras especiais", "tag.name.ART": "Prêmio(s) em arte", "tag.name.MONEY": "Prêmio(s) em dinheiro", diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json index 1afc860be..b0b955733 100644 --- a/locales/ru/calendar.json +++ b/locales/ru/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "URL приглашения на Discord-сервер", "forms.tags": "Теги", "forms.tags.placeholder": "Выберите тег", - "forms.tags.info": "Тег \"Призовые значки\" добавляется автоматически, если это возможно", "forms.badges": "Призовые значки", "forms.badges.placeholder": "Выберите призовой значок", "forms.mapPool": "Список карт", diff --git a/locales/ru/common.json b/locales/ru/common.json index e80602f62..21e60745f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -163,7 +163,6 @@ "errors.genericReload": "Что-то пошло не так. Попробуйте обновить страницу.", - "tag.name.BADGE": "Призовые значки", "tag.name.SPECIAL": "Особые правила", "tag.name.ART": "Арт-призы", "tag.name.MONEY": "Днежные призы", diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json index eb6fae9d4..8e8a72b98 100644 --- a/locales/zh/calendar.json +++ b/locales/zh/calendar.json @@ -24,7 +24,6 @@ "forms.discordInvite": "Discord服务器的邀请链接", "forms.tags": "标签", "forms.tags.placeholder": "选择标签", - "forms.tags.info": "标签\"徽章奖励\"将根据情况自动添加", "forms.badges": "徽章奖励", "forms.badges.placeholder": "选择一项徽章奖励", "forms.mapPool": "地图池", diff --git a/locales/zh/common.json b/locales/zh/common.json index 3d372529a..85fd30284 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -104,7 +104,6 @@ "errors.genericReload": "发生错误,请重新加载本页面。", - "tag.name.BADGE": "徽章奖励", "tag.name.SPECIAL": "特殊规则", "tag.name.ART": "插画奖励", "tag.name.MONEY": "奖金", diff --git a/package-lock.json b/package-lock.json index c70b9e7f2..5222c5a99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.797.0", "@aws-sdk/lib-storage": "^3.797.0", + "@date-fns/tz": "^1.2.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -1633,6 +1634,12 @@ "node": ">=14.21.3" } }, + "node_modules/@date-fns/tz": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", + "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==", + "license": "MIT" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", diff --git a/package.json b/package.json index 8a126f891..c6ae05702 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "typecheck": "tsc --noEmit", "test:unit": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only run", "test:e2e": "npx playwright test", + "test:e2e:flaky-detect": "npx playwright test --repeat-each=10 --max-failures=1", "checks": "npm run biome:fix && npm run test:unit && npm run check-translation-jsons && npm run typecheck" }, "dependencies": { "@aws-sdk/client-s3": "^3.797.0", "@aws-sdk/lib-storage": "^3.797.0", + "@date-fns/tz": "^1.2.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/public/static-assets/img/modes/SR.avif b/public/static-assets/img/modes/SR.avif new file mode 100644 index 000000000..f41787c60 Binary files /dev/null and b/public/static-assets/img/modes/SR.avif differ diff --git a/public/static-assets/img/modes/SR.png b/public/static-assets/img/modes/SR.png new file mode 100644 index 000000000..5fd06bea5 Binary files /dev/null and b/public/static-assets/img/modes/SR.png differ diff --git a/public/static-assets/img/modes/TB.avif b/public/static-assets/img/modes/TB.avif new file mode 100644 index 000000000..e96cac8a3 Binary files /dev/null and b/public/static-assets/img/modes/TB.avif differ diff --git a/public/static-assets/img/modes/TB.png b/public/static-assets/img/modes/TB.png new file mode 100644 index 000000000..2ec6875a0 Binary files /dev/null and b/public/static-assets/img/modes/TB.png differ diff --git a/scripts/check-translation-jsons.ts b/scripts/check-translation-jsons.ts index 791d26895..2f27dab9e 100644 --- a/scripts/check-translation-jsons.ts +++ b/scripts/check-translation-jsons.ts @@ -74,12 +74,6 @@ for (const file of fileNames) { file, ); - validateNoExtraKeysInOther({ - english: getKeysWithoutSuffix(englishContent, lang, file), - other: otherLanguageContentKeys, - lang, - file, - }); validateVariables({ english: englishContent, other: otherLanguageContent, @@ -130,26 +124,6 @@ const translationProgressPath = path.join( fs.writeFileSync(translationProgressPath, markdown); -function validateNoExtraKeysInOther({ - english, - other, - lang, - file, -}: { - english: string[]; - other: string[]; - lang: string; - file: string; -}) { - const validKeys = english; - - for (const key of other) { - if (validKeys.includes(key)) continue; - - throw new Error(`unknown key in ${lang}/${file}: ${key}`); - } -} - function validateVariables({ english, other,