Filter calendar events by tags (#1972)

* backend

* Tournament docs only visible when adding tournament

* Progress

* Finish?

* Fix
This commit is contained in:
Kalle
2024-11-25 23:14:49 +02:00
committed by GitHub
parent 6d654baa30
commit dd53b32a7e
10 changed files with 262 additions and 78 deletions

View File

@@ -33,7 +33,7 @@ import { mySlugify } from "~/utils/urls";
import type { SeedVariation } from "~/features/api-private/routes/seed";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { tags } from "~/features/calendar/calendar-constants";
import { persistedTags } from "~/features/calendar/calendar-constants";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { TIMEZONES } from "~/features/lfg/lfg-constants";
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
@@ -698,9 +698,7 @@ function calendarEvents() {
const userIds = userIdsInRandomOrder();
for (let id = 1; id <= AMOUNT_OF_CALENDAR_EVENTS; id++) {
const shuffledTags = shuffle(Object.keys(tags)).filter(
(tag) => tag !== "BADGE",
);
const shuffledTags = shuffle(Object.keys(persistedTags));
sql
.prepare(

View File

@@ -1,4 +1,7 @@
import type { tags } from "~/features/calendar/calendar-constants";
import type {
persistedTags,
tags,
} from "~/features/calendar/calendar-constants";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { TEAM_MEMBER_ROLES } from "~/features/team";
import type {
@@ -135,6 +138,7 @@ export interface CalendarEvent {
tournamentId: number | null;
}
export type PersistedCalendarEventTag = keyof typeof persistedTags;
export type CalendarEventTag = keyof typeof tags;
export interface CalendarEventDate {

View File

@@ -44,5 +44,10 @@ function fetchEventsOfWeek(args: { week: number; year: number }) {
const endTime = new Date(startTime);
endTime.setDate(endTime.getDate() + 7);
return CalendarRepository.findAllBetweenTwoTimestamps({ startTime, endTime });
return CalendarRepository.findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy: [],
onlyTournaments: false,
});
}

View File

@@ -3,7 +3,7 @@ import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, Tables, TournamentSettings } from "~/db/tables";
import type { CalendarEventTag } from "~/db/types";
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/types";
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";
@@ -143,11 +143,15 @@ export type FindAllBetweenTwoTimestampsItem = Unwrapped<
export async function findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy,
onlyTournaments,
}: {
startTime: Date;
endTime: Date;
tagsToFilterBy: Array<PersistedCalendarEventTag>;
onlyTournaments: boolean;
}) {
const rows = await db
let query = db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
@@ -211,8 +215,17 @@ export async function findAllBetweenTwoTimestamps({
"<=",
dateToDatabaseTimestamp(endTime),
)
.orderBy("CalendarEventDate.startTime", "asc")
.execute();
.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
@@ -305,17 +318,30 @@ async function tournamentParticipantCount({
export async function startTimesOfRange({
startTime,
endTime,
tagsToFilterBy,
onlyTournaments,
}: {
startTime: Date;
endTime: Date;
tagsToFilterBy: Array<PersistedCalendarEventTag>;
onlyTournaments: boolean;
}) {
const rows = await db
let query = db
.selectFrom("CalendarEventDate")
.innerJoin("CalendarEvent", "CalendarEvent.id", "CalendarEventDate.eventId")
.select(["startTime"])
.where("startTime", ">=", dateToDatabaseTimestamp(startTime))
.where("startTime", "<=", dateToDatabaseTimestamp(endTime))
.execute();
.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);
}

View File

@@ -2,7 +2,7 @@ import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { z } from "zod";
import { TOURNAMENT_STAGE_TYPES } from "~/db/tables";
import type { CalendarEventTag } from "~/db/types";
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/types";
import { requireUser } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
@@ -212,6 +212,12 @@ export const bracketProgressionSchema = z.preprocess(
),
);
export const calendarEventTagSchema = z
.string()
.refine((val) =>
CALENDAR_EVENT.PERSISTED_TAGS.includes(val as PersistedCalendarEventTag),
);
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),
@@ -252,15 +258,7 @@ export const newCalendarEventActionSchema = z
),
tags: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z
.array(
z
.string()
.refine((val) =>
CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag),
),
)
.nullable(),
z.array(calendarEventTagSchema).nullable(),
),
badges: z.preprocess(
processMany(safeJSONParse, removeDuplicates),

View File

@@ -1,9 +1,6 @@
import type { CalendarEventTag } from "~/db/types";
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/types";
export const tags = {
BADGE: {
color: "#000",
},
export const persistedTags = {
SPECIAL: {
color: "#CE93D8",
},
@@ -60,6 +57,13 @@ export const tags = {
},
};
export const tags = {
...persistedTags,
BADGE: {
color: "#000",
},
};
export const CALENDAR_EVENT = {
NAME_MIN_LENGTH: 2,
NAME_MAX_LENGTH: 100,
@@ -68,6 +72,11 @@ export const CALENDAR_EVENT = {
DISCORD_INVITE_CODE_MAX_LENGTH: 50,
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<PersistedCalendarEventTag>,
/** Calendar event tag, both those persisted in the database and those that are computed */
TAGS: Object.keys(tags) as Array<CalendarEventTag>,
AVATAR_SIZE: 512,
};

View File

@@ -110,15 +110,17 @@ export default function CalendarNewEventPage() {
<h1 className="text-lg">
{data.isAddingTournament ? "New tournament" : "New calendar event"}
</h1>
<a
href={CREATING_TOURNAMENT_DOC_LINK}
className="text-lg text-bold"
title="Documentation about creating tournaments"
target="_blank"
rel="noopener noreferrer"
>
?
</a>
{data.isAddingTournament ? (
<a
href={CREATING_TOURNAMENT_DOC_LINK}
className="text-lg text-bold"
title="Documentation about creating tournaments"
target="_blank"
rel="noopener noreferrer"
>
?
</a>
) : null}
</div>
{data.isAddingTournament ? <TemplateTournamentForm /> : null}
<EventForm key={baseEvent?.eventId} />
@@ -572,9 +574,8 @@ function TagsAdder() {
const [tags, setTags] = React.useState(baseEvent?.tags ?? []);
const id = React.useId();
const tagsForSelect = CALENDAR_EVENT.TAGS.filter(
// @ts-expect-error TODO: fix this (5.5 version)
(tag) => !tags.includes(tag) && tag !== "BADGE",
const tagsForSelect = CALENDAR_EVENT.PERSISTED_TAGS.filter(
(tag) => !tags.includes(tag),
);
return (

View File

@@ -3,7 +3,7 @@ import type {
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import clsx from "clsx";
import { addDays, addMonths, subDays, subMonths } from "date-fns";
import React from "react";
@@ -14,15 +14,12 @@ import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { LinkButton } from "~/components/Button";
import { Divider } from "~/components/Divider";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { Toggle } from "~/components/Toggle";
import { UsersIcon } from "~/components/icons/Users";
import { getUserId } from "~/features/auth/core/user.server";
import { currentSeason } from "~/features/mmr/season";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { i18next } from "~/modules/i18n/i18next.server";
import { joinListToNaturalString } from "~/utils/arrays";
import {
@@ -45,8 +42,16 @@ import {
tournamentPage,
userSubmittedImage,
} from "~/utils/urls";
import { actualNumber } from "~/utils/zod";
import { actualNumber, safeSplit } from "~/utils/zod";
import { Label } from "../../../components/Label";
import { Toggle } from "../../../components/Toggle";
import type {
CalendarEventTag,
PersistedCalendarEventTag,
} from "../../../db/types";
import * as CalendarRepository from "../CalendarRepository.server";
import { calendarEventTagSchema } from "../actions/calendar.new.server";
import { CALENDAR_EVENT } from "../calendar-constants";
import { Tags } from "../components/Tags";
import "~/styles/calendar.css";
@@ -78,29 +83,52 @@ export const handle: SendouRouteHandle = {
}),
};
const loaderSearchParamsSchema = z.object({
const loaderWeekSearchParamsSchema = z.object({
week: z.preprocess(actualNumber, z.number().int().min(1).max(53)),
year: z.preprocess(actualNumber, z.number().int()),
});
const loaderFilterSearchParamsSchema = z.object({
tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)),
});
const loaderTournamentsOnlySearchParamsSchema = z.object({
tournaments: z.literal("true").nullish(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
const t = await i18next.getFixedT(request);
const url = new URL(request.url);
const parsedParams = loaderSearchParamsSchema.safeParse({
// separate from tags parse so they can fail independently
const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({
year: url.searchParams.get("year"),
week: url.searchParams.get("week"),
});
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
tags: url.searchParams.get("tags"),
});
const parsedTournamentsOnlyParams =
loaderTournamentsOnlySearchParamsSchema.safeParse({
tournaments: url.searchParams.get("tournaments"),
});
const mondayDate = dateToThisWeeksMonday(new Date());
const currentWeek = dateToWeekNumber(mondayDate);
const displayedWeek = parsedParams.success
? parsedParams.data.week
const displayedWeek = parsedWeekParams.success
? parsedWeekParams.data.week
: currentWeek;
const displayedYear = parsedParams.success
? parsedParams.data.year
const displayedYear = parsedWeekParams.success
? parsedWeekParams.data.year
: mondayDate.getFullYear();
const tagsToFilterBy = parsedFilterParams.success
? (parsedFilterParams.data.tags as PersistedCalendarEventTag[])
: [];
const onlyTournaments = parsedTournamentsOnlyParams.success
? Boolean(parsedTournamentsOnlyParams.data.tournaments)
: false;
return {
currentWeek,
@@ -115,11 +143,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
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)
@@ -144,7 +176,12 @@ function closeByWeeks(args: { week: number; year: number }) {
});
}
function fetchEventsOfWeek(args: { week: number; year: number }) {
function fetchEventsOfWeek(args: {
week: number;
year: number;
tagsToFilterBy: PersistedCalendarEventTag[];
onlyTournaments: boolean;
}) {
const startTime = weekNumberToDate(args);
const endTime = new Date(startTime);
@@ -154,51 +191,38 @@ function fetchEventsOfWeek(args: { week: number; year: number }) {
startTime.setHours(startTime.getHours() - 12);
endTime.setHours(endTime.getHours() + 12);
return CalendarRepository.findAllBetweenTwoTimestamps({ startTime, endTime });
return CalendarRepository.findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy: args.tagsToFilterBy,
onlyTournaments: args.onlyTournaments,
});
}
export default function CalendarPage() {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [onlySendouInkEvents, setOnlySendouInkEvents] = useSearchParamState({
defaultValue: false,
name: "tournaments",
revive: (val) => val === "true",
});
const filteredEvents = onlySendouInkEvents
? data.events.filter((event) => event.tournamentId)
: data.events;
// 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
? filteredEvents.filter(
? data.events.filter(
(event) =>
dateToWeekNumber(
dateToSixHoursAgo(databaseTimestampToDate(event.startTime)),
) === data.displayedWeek,
)
: filteredEvents;
: data.events;
return (
<Main classNameOverwrite="stack lg main layout__main">
<WeekLinks />
<EventsToReport />
<div>
<div className="stack horizontal justify-end">
<div className="stack horizontal sm items-center">
<Toggle
id="onlySendouInk"
tiny
checked={onlySendouInkEvents}
setChecked={setOnlySendouInkEvents}
/>
<Label spaced={false} htmlFor="onlySendouInk">
Only sendou.ink events
</Label>
</div>
<div className="stack horizontal justify-between">
<TagsFilter />
<OnSendouInkToggle />
</div>
{isMounted ? (
<>
@@ -225,11 +249,20 @@ export default function CalendarPage() {
function WeekLinks() {
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [searchParams] = useSearchParams();
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()}`;
};
return (
<Flipper flipKey={data.weeks.map(({ number }) => number).join("")}>
<div className="flex justify-center">
@@ -247,7 +280,7 @@ function WeekLinks() {
return (
<Flipped key={week.number} flipId={week.number}>
<Link
to={`?week=${week.number}&year=${week.year}`}
to={linkTo({ week: week.number, year: week.year })}
className={clsx("calendar__week", { invisible: hidden })}
aria-hidden={hidden}
tabIndex={hidden || isCurrentWeek ? -1 : 0}
@@ -359,6 +392,98 @@ function EventsToReport() {
);
}
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 (
<div className="stack sm">
<div>
<label htmlFor={id}>{t("calendar:tag.filter.label")}</label>
<select
id={id}
className="w-max"
onChange={(e) =>
setTagsToFilterBy([
...tagsToFilterBy,
e.target.value as CalendarEventTag,
])
}
>
<option value=""></option>
{tagsForSelect.map((tag) => (
<option key={tag} value={tag}>
{t(`common:tag.name.${tag}`)}
</option>
))}
</select>
</div>
<Tags
tags={tagsToFilterBy}
onDelete={(tagToDelete) =>
setTagsToFilterBy(tagsToFilterBy.filter((tag) => tag !== tagToDelete))
}
/>
</div>
);
}
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 (
<div className="stack horizontal justify-end">
<div className="stack items-end">
<Label htmlFor="onlyTournaments">
{t("calendar:tournament.filter.label")}
</Label>
<Toggle
id="onlyTournaments"
checked={onlyTournaments}
setChecked={setOnlyTournaments}
/>
</div>
</div>
);
}
function EventsList({
events,
}: {

View File

@@ -97,6 +97,21 @@ export function safeJSONParse(value: unknown): unknown {
}
}
/**
* Safely splits a string by a specified delimiter as Zod preprocess function.
*
* @param splitBy - The delimiter to split the string by. Defaults to a comma (",").
* @returns A function that takes a value and returns the split string if the value is a string,
* otherwise returns the original value.
*/
export const safeSplit =
(splitBy = ",") =>
(value: unknown): unknown => {
if (typeof value !== "string") return value;
return value.split(splitBy);
};
export function falsyToNull(value: unknown): unknown {
if (value) return value;

View File

@@ -65,5 +65,8 @@
"tag.desc.S1": "The game played is Splatoon 1.",
"tag.desc.S2": "The game played is Splatoon 2.",
"tag.desc.SR": "Salmon Run event.",
"tag.desc.CARDS": "Tableturf Battle event."
"tag.desc.CARDS": "Tableturf Battle event.",
"tag.filter.label": "Filter by tags",
"tournament.filter.label": "Hosted on sendou.ink"
}