From dd53b32a7e94fbbe0ce8ad36a7ca30298353285e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 25 Nov 2024 23:14:49 +0200 Subject: [PATCH] Filter calendar events by tags (#1972) * backend * Tournament docs only visible when adding tournament * Progress * Finish? * Fix --- app/db/seed/index.ts | 6 +- app/db/types.ts | 6 +- .../api-public/routes/calendar.$year.$week.ts | 7 +- .../calendar/CalendarRepository.server.ts | 40 +++- .../calendar/actions/calendar.new.server.ts | 18 +- app/features/calendar/calendar-constants.ts | 19 +- app/features/calendar/routes/calendar.new.tsx | 25 +-- app/features/calendar/routes/calendar.tsx | 199 ++++++++++++++---- app/utils/zod.ts | 15 ++ locales/en/calendar.json | 5 +- 10 files changed, 262 insertions(+), 78 deletions(-) diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 6aee71001..c091d3270 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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( diff --git a/app/db/types.ts b/app/db/types.ts index 0edb56786..c30d41099 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -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 { diff --git a/app/features/api-public/routes/calendar.$year.$week.ts b/app/features/api-public/routes/calendar.$year.$week.ts index 57bb460be..ee1509f0d 100644 --- a/app/features/api-public/routes/calendar.$year.$week.ts +++ b/app/features/api-public/routes/calendar.$year.$week.ts @@ -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, + }); } diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 550cac760..909602df9 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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; + 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; + 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); } diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 9f2303e60..4c403cc94 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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), diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts index 7a13bc8e7..ac430c95e 100644 --- a/app/features/calendar/calendar-constants.ts +++ b/app/features/calendar/calendar-constants.ts @@ -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, + /** Calendar event tag, both those persisted in the database and those that are computed */ TAGS: Object.keys(tags) as Array, AVATAR_SIZE: 512, }; diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 8f019ba80..8fa19d050 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -110,15 +110,17 @@ export default function CalendarNewEventPage() {

{data.isAddingTournament ? "New tournament" : "New calendar event"}

- - ? - + {data.isAddingTournament ? ( + + ? + + ) : null} {data.isAddingTournament ? : null} @@ -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 ( diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index c14fc084c..dc3ce8ee6 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -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(); 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 (
-
-
- - -
+
+ +
{isMounted ? ( <> @@ -225,11 +249,20 @@ export default function CalendarPage() { function WeekLinks() { const data = useLoaderData(); 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 ( number).join("")}>
@@ -247,7 +280,7 @@ function WeekLinks() { return ( 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 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, }: { diff --git a/app/utils/zod.ts b/app/utils/zod.ts index 4514aa871..ba70324cb 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -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; diff --git a/locales/en/calendar.json b/locales/en/calendar.json index b57dd474d..5b68e9b2e 100644 --- a/locales/en/calendar.json +++ b/locales/en/calendar.json @@ -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" }