diff --git a/atcoder-problems-frontend/src/pages/ListPage/ListTable.tsx b/atcoder-problems-frontend/src/pages/ListPage/ListTable.tsx index 35dd95b49..031fa5bce 100644 --- a/atcoder-problems-frontend/src/pages/ListPage/ListTable.tsx +++ b/atcoder-problems-frontend/src/pages/ListPage/ListTable.tsx @@ -42,6 +42,8 @@ import { useProblemModelMap, useRatingInfo, } from "../../api/APIClient"; +import { classifyContest } from "../../utils/ContestClassifier"; +import { getLikeContestCategory } from "../../utils/LikeContestUtils"; export const INF_POINT = 1e18; @@ -113,6 +115,8 @@ interface Props { | "Only Rated" | "Only Unrated" | "Only Unrated without Difficulty"; + contestCategoryFilterState: string; + mergeLikeContest: boolean; fromDifficulty: number; toDifficulty: number; filteredSubmissions: Submission[]; @@ -629,6 +633,18 @@ export const ListTable: React.FC = (props) => { return !isRated && !hasDifficulty; } }) + .filter((row): boolean => { + if (props.contestCategoryFilterState === "All") return true; + if (!row.contest) return false; + const contest = contestMap?.get(row.contest.id); + const contestCategory = classifyContest(contest); + return ( + props.contestCategoryFilterState === contestCategory || + (props.mergeLikeContest && + getLikeContestCategory(props.contestCategoryFilterState) === + contestCategory) + ); + }) .filter((row) => { const difficulty = isProblemModelWithDifficultyModel(row.problemModel) ? row.problemModel.difficulty diff --git a/atcoder-problems-frontend/src/pages/ListPage/ProblemList.tsx b/atcoder-problems-frontend/src/pages/ListPage/ProblemList.tsx index 6d16e28e0..8c9e3b407 100644 --- a/atcoder-problems-frontend/src/pages/ListPage/ProblemList.tsx +++ b/atcoder-problems-frontend/src/pages/ListPage/ProblemList.tsx @@ -1,5 +1,5 @@ import { Range } from "immutable"; -import React from "react"; +import { React, useMemo, useState } from "react"; import { Link, useHistory, useLocation } from "react-router-dom"; import { Button, @@ -24,6 +24,8 @@ import { } from "../../utils/ProblemSelection"; import { useLoginState } from "../../api/InternalAPIClient"; import { useMergedProblemMap } from "../../api/APIClient"; +import { ContestCategories } from "../../utils/ContestClassifier"; +import { isLikeContestCategory } from "../../utils/LikeContestUtils"; import { INF_POINT, ListTable, StatusFilter, statusFilters } from "./ListTable"; export const FilterParams = { @@ -31,8 +33,10 @@ export const FilterParams = { ToPoint: "toPo", Status: "status", Rated: "rated", + Category: "category", FromDifficulty: "fromDiff", ToDifficulty: "toDiff", + mergeLikeContest: "mergeLikeContest", Language: "Lang", } as const; @@ -55,12 +59,14 @@ const RATED_FILTERS = [ ] as const; type RatedFilter = typeof RATED_FILTERS[number]; +const categoryFilters = ["All", ...ContestCategories] as const; +type CategoryFilter = typeof categoryFilters[number]; interface Props { userId: string; submissions: Submission[]; } -export const ProblemList: React.FC = (props) => { +export const ProblemList: React.FC = (props: Props) => { const location = useLocation(); const history = useHistory(); @@ -91,6 +97,10 @@ export const ProblemList: React.FC = (props) => { const ratedFilterState: RatedFilter = RATED_FILTERS.find((x) => x === searchParams.get(FilterParams.Rated)) ?? "All"; + const contestCategoryFilterState: CategoryFilter = + categoryFilters.find( + (x) => x === searchParams.get(FilterParams.Category) + ) ?? "All"; const languages = ["All"].concat( Array.from( @@ -112,6 +122,7 @@ export const ProblemList: React.FC = (props) => { searchParams.get(FilterParams.ToDifficulty) || INF_POINT.toString(), 10 ); + const [mergeLikeContest, setMergeLikeContest] = useState(true); const mergedProblemMap = useMergedProblemMap().data ?? new Map(); const points = Array.from( @@ -130,6 +141,14 @@ export const ProblemList: React.FC = (props) => { const loginState = useLoginState().data; const isLoggedIn = UserState.isLoggedIn(loginState); + const filteredCategoryFilters = useMemo(() => { + return categoryFilters.filter( + (category) => + category === "All" || + !mergeLikeContest || + !isLikeContestCategory(category) + ); + }, [mergeLikeContest]); return ( <> @@ -210,6 +229,33 @@ export const ProblemList: React.FC = (props) => { + + + + {contestCategoryFilterState === "All" + ? "Category" + : contestCategoryFilterState} + + + {filteredCategoryFilters.map((value) => ( + + {value} + + ))} + + + + @@ -325,6 +371,8 @@ export const ProblemList: React.FC = (props) => { toPoint={toPoint} statusFilterState={statusFilterState} ratedFilterState={ratedFilterState} + contestCategoryFilterState={contestCategoryFilterState} + mergeLikeContest={mergeLikeContest} fromDifficulty={fromDifficulty} toDifficulty={toDifficulty} filteredSubmissions={props.submissions} diff --git a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/RecommendController.tsx b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/RecommendController.tsx index aa8410a03..9f4639fa3 100644 --- a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/RecommendController.tsx +++ b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/RecommendController.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import { React, useMemo } from "react"; import { Button, ButtonGroup, @@ -9,7 +9,7 @@ import { UncontrolledDropdown, } from "reactstrap"; import { formatExcludeOption } from "../../../utils/LastSolvedTime"; - +import { isLikeContestCategory } from "../../../utils/LikeContestUtils"; const RECOMMEND_NUM_OPTIONS = [ { text: "10", @@ -47,6 +47,18 @@ const ExcludeOptions = [ ] as const; export type ExcludeOption = typeof ExcludeOptions[number]; +const CategoryOptions = [ + "All", + "ABC", + "ARC", + "AGC", + "ABC-Like", + "ARC-Like", + "AGC-Like", + "Other Sponsored", +] as const; +export type CategoryOption = typeof CategoryOptions[number]; + interface Props { recommendOption: RecommendOption; onChangeRecommendOption: (option: RecommendOption) => void; @@ -54,73 +66,123 @@ interface Props { excludeOption: ExcludeOption; onChangeExcludeOption: (option: ExcludeOption) => void; + categoryOption: CategoryOption; + onChangeCategoryOption: (option: CategoryOption) => void; + showExperimental: boolean; onChangeExperimentalVisibility: (showExperimental: boolean) => void; + mergeLikeContest: boolean; + onChangeMergeLikeContest: (mergeLikeContest: boolean) => void; showCount: number; onChangeShowCount: (count: number) => void; } -export const RecommendController = (props: Props) => ( - <> -
- - {RecommendOptions.map((type) => ( - - ))} - - - - - {formatExcludeOption(props.excludeOption)} - - - {ExcludeOptions.map((option) => ( - props.onChangeExcludeOption(option)} - > - {formatExcludeOption(option)} - - ))} - - - - - 🧪 - - } - checked={props.showExperimental} - onChange={() => - props.onChangeExperimentalVisibility(!props.showExperimental) - } - /> -
- - - {props.showCount === Number.POSITIVE_INFINITY ? "All" : props.showCount} - - - {RECOMMEND_NUM_OPTIONS.map(({ text, value }) => ( - props.onChangeShowCount(value)} - > - {text} - - ))} - - - -); +export const RecommendController = (props: Props) => { + const filteredCategories = useMemo(() => { + return CategoryOptions.filter( + (category) => + category === "All" || + !props.mergeLikeContest || + !isLikeContestCategory(category) + ); + }, [props.mergeLikeContest]); + return ( + <> +
+ + {RecommendOptions.map((type) => ( + + ))} + + + + + {formatExcludeOption(props.excludeOption)} + + + {ExcludeOptions.map((option) => ( + props.onChangeExcludeOption(option)} + > + {formatExcludeOption(option)} + + ))} + + + + + + + {props.categoryOption === "All" + ? "Contest Category" + : props.categoryOption} + + + {filteredCategories.map((option) => ( + props.onChangeCategoryOption(option)} + > + {option} + + ))} + + + + + 🧪 + + } + checked={props.showExperimental} + onChange={() => + props.onChangeExperimentalVisibility(!props.showExperimental) + } + /> + + Merge "-Like" Contests + + } + checked={props.mergeLikeContest} + onChange={() => + props.onChangeMergeLikeContest(!props.mergeLikeContest) + } + /> +
+ + + {props.showCount === Number.POSITIVE_INFINITY + ? "All" + : props.showCount} + + + {RECOMMEND_NUM_OPTIONS.map(({ text, value }) => ( + props.onChangeShowCount(value)} + > + {text} + + ))} + + + + ); +}; diff --git a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/RecommendProblems.ts b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/RecommendProblems.ts index 7020bc947..320bebd2d 100644 --- a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/RecommendProblems.ts +++ b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/RecommendProblems.ts @@ -3,7 +3,7 @@ import ProblemModel, { isProblemModelWithDifficultyModel, isProblemModelWithTimeModel, } from "../../../interfaces/ProblemModel"; -import { ProblemId } from "../../../interfaces/Status"; +import { ContestId, ProblemId } from "../../../interfaces/Status"; import { predictSolveProbability, predictSolveTime, @@ -58,6 +58,7 @@ const getRecommendProbabilityRange = ( export const recommendProblems = ( problems: Problem[], isIncluded: (problemId: ProblemId) => boolean, + isIncludedContestCategory: (contestId: ContestId) => boolean, getProblemModel: (problemId: ProblemId) => ProblemModel | undefined, recommendExperimental: boolean, internalRating: number | null, @@ -68,6 +69,7 @@ export const recommendProblems = ( const recommendRange = getRecommendProbabilityRange(recommendOption); return problems .filter((p) => isIncluded(p.id)) + .filter((p) => isIncludedContestCategory(p.contest_id)) .map((p) => ({ ...p, difficulty: getProblemModel(p.id)?.difficulty, diff --git a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.tsx b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.tsx index 9c556870e..7c75c4b64 100644 --- a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.tsx +++ b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.tsx @@ -15,7 +15,7 @@ import { HelpBadgeTooltip } from "../../../components/HelpBadgeTooltip"; import { NewTabLink } from "../../../components/NewTabLink"; import { ProblemLink } from "../../../components/ProblemLink"; import Problem from "../../../interfaces/Problem"; -import { ProblemId } from "../../../interfaces/Status"; +import { ContestId, ProblemId } from "../../../interfaces/Status"; import { formatPredictedSolveProbability, formatPredictedSolveTime, @@ -34,8 +34,14 @@ import { getLastSolvedTimeMap, getMaximumExcludeElapsedSecond, } from "../../../utils/LastSolvedTime"; +import { classifyContest } from "../../../utils/ContestClassifier"; +import { getLikeContestCategory } from "../../../utils/LikeContestUtils"; import { recommendProblems } from "./RecommendProblems"; -import { RecommendController, RecommendOption } from "./RecommendController"; +import { + CategoryOption, + RecommendController, + RecommendOption, +} from "./RecommendController"; interface Props { userId: string; @@ -54,6 +60,16 @@ export const Recommendations = (props: Props) => { "recoomendExcludeOption", "Exclude" ); + + const [mergeLikeContest, setMergeLikeContest] = useLocalStorage( + "recommendMergeLikeContest", + true + ); + + const [categoryOption, setCategoryOption] = useLocalStorage( + "recommendCategoryOption", + "All" + ); const [recommendNum, setRecommendNum] = useState(10); const [ @@ -105,6 +121,18 @@ export const Recommendations = (props: Props) => { : Number.MAX_SAFE_INTEGER; return getMaximumExcludeElapsedSecond(excludeOption) < elapsedSecond; }, + (contestId: ContestId) => { + if (categoryOption === "All") return true; + const contest = contestMap?.get(contestId); + if (!contest) { + return false; + } + return ( + classifyContest(contest) === categoryOption || + (mergeLikeContest && + classifyContest(contest) === getLikeContestCategory(categoryOption)) + ); + }, (problemId: ProblemId) => problemModels?.get(problemId), recommendExperimental, userRatingInfo.internalRating, @@ -120,10 +148,14 @@ export const Recommendations = (props: Props) => { onChangeRecommendOption={(option) => setRecommendOption(option)} excludeOption={excludeOption} onChangeExcludeOption={(option) => setExcludeOption(option)} + categoryOption={categoryOption} + onChangeCategoryOption={(option) => setCategoryOption(option)} showExperimental={recommendExperimental} onChangeExperimentalVisibility={(show) => setRecommendExperimental(show) } + mergeLikeContest={mergeLikeContest} + onChangeMergeLikeContest={(merge) => setMergeLikeContest(merge)} showCount={recommendNum} onChangeShowCount={(value) => setRecommendNum(value)} /> diff --git a/atcoder-problems-frontend/src/utils/LocalStorage.tsx b/atcoder-problems-frontend/src/utils/LocalStorage.tsx index 7cfa14de2..f78e14923 100644 --- a/atcoder-problems-frontend/src/utils/LocalStorage.tsx +++ b/atcoder-problems-frontend/src/utils/LocalStorage.tsx @@ -16,7 +16,9 @@ const LocalStorageKeys = [ "showRating", "recommendOption", "recommendExperimental", + "recommendMergeLikeContest", "recoomendExcludeOption", + "recommendCategoryOption", "MergeLikeContest", ] as const; type LocalStorageKey = typeof LocalStorageKeys[number];