├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── atcoder-problems-backend ├── .dockerignore ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── Dockerfile.dev ├── README.md ├── atcoder-client │ ├── Cargo.toml │ ├── src │ │ ├── atcoder.rs │ │ ├── atcoder │ │ │ ├── client.rs │ │ │ ├── contest.rs │ │ │ ├── problem.rs │ │ │ ├── submission.rs │ │ │ └── types.rs │ │ ├── lib.rs │ │ └── util.rs │ └── test_resources │ │ ├── abc107_submissions │ │ ├── abc107_tasks │ │ ├── atc002_tasks │ │ ├── contests_normal │ │ └── contests_permanent ├── scripts │ └── sql-backup.sh ├── sql-client │ ├── Cargo.toml │ ├── src │ │ ├── accepted_count.rs │ │ ├── contest_problem.rs │ │ ├── internal │ │ │ ├── mod.rs │ │ │ ├── problem_list_manager.rs │ │ │ ├── progress_reset_manager.rs │ │ │ ├── user_manager.rs │ │ │ └── virtual_contest_manager.rs │ │ ├── language_count.rs │ │ ├── lib.rs │ │ ├── models.rs │ │ ├── problem_info.rs │ │ ├── problems_submissions.rs │ │ ├── rated_point_sum.rs │ │ ├── simple_client.rs │ │ ├── streak.rs │ │ └── submission_client.rs │ └── tests │ │ ├── test_accepted_count.rs │ │ ├── test_contest_problem.rs │ │ ├── test_language_count.rs │ │ ├── test_problem_info.rs │ │ ├── test_problem_list_manager.rs │ │ ├── test_problems_submissions.rs │ │ ├── test_progress_reset_manager.rs │ │ ├── test_rated_point_sum.rs │ │ ├── test_simple_client.rs │ │ ├── test_streak.rs │ │ ├── test_submission_client.rs │ │ ├── test_user_manager.rs │ │ ├── test_virtual_contest_manager.rs │ │ └── utils │ │ └── mod.rs ├── src │ ├── bin │ │ ├── batch_update.rs │ │ ├── crawl_all_submissions.rs │ │ ├── crawl_for_virtual_contests.rs │ │ ├── crawl_from_new_contests.rs │ │ ├── crawl_problems.rs │ │ ├── crawl_recent_submissions.rs │ │ ├── crawl_whole_contest.rs │ │ ├── delta_update.rs │ │ ├── dump_json.rs │ │ ├── fix_invalid_submissions.rs │ │ └── run_server.rs │ ├── config.rs │ ├── crawler │ │ ├── fix_crawler.rs │ │ ├── mod.rs │ │ ├── problem_crawler.rs │ │ ├── recent_crawler.rs │ │ ├── utils.rs │ │ ├── virtual_contest_crawler.rs │ │ └── whole_contest_crawler.rs │ ├── lib.rs │ ├── s3.rs │ ├── server │ │ ├── endpoint │ │ │ ├── healthcheck.rs │ │ │ ├── internal_api │ │ │ │ ├── contest │ │ │ │ │ ├── item.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── list │ │ │ │ │ ├── item.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── progress_reset.rs │ │ │ │ └── user.rs │ │ │ └── mod.rs │ │ ├── error.rs │ │ ├── language_count.rs │ │ ├── middleware │ │ │ ├── github_auth.rs │ │ │ └── mod.rs │ │ ├── mod.rs │ │ ├── ranking │ │ │ ├── ac_count.rs │ │ │ ├── language.rs │ │ │ ├── mod.rs │ │ │ ├── rated_point_sum.rs │ │ │ └── streak.rs │ │ ├── services.rs │ │ ├── time_submissions.rs │ │ ├── user_info.rs │ │ └── user_submissions.rs │ └── utils.rs └── tests │ ├── test_server_e2e_ac_ranking.rs │ ├── test_server_e2e_language_count.rs │ ├── test_server_e2e_language_ranking.rs │ ├── test_server_e2e_problem_list.rs │ ├── test_server_e2e_progress_reset.rs │ ├── test_server_e2e_rated_point_sum_ranking.rs │ ├── test_server_e2e_streak_ranking.rs │ ├── test_server_e2e_submissions.rs │ ├── test_server_e2e_virtual_contest.rs │ └── utils.rs ├── atcoder-problems-frontend ├── .editorconfig ├── .env ├── .env.development ├── .eslintrc.js ├── .gitignore ├── .prettierrc.json ├── .yarnrc ├── README.md ├── cypress.json ├── cypress │ ├── fixtures │ │ └── results │ │ │ ├── empty.json │ │ │ ├── rival.json │ │ │ └── user.json │ ├── integration │ │ ├── common.ts │ │ └── table.spec.ts │ ├── plugins │ │ └── index.js │ ├── support │ │ ├── commands.js │ │ └── index.js │ └── tsconfig.json ├── package.json ├── public │ ├── apple-touch-icon.png │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── static_data │ │ ├── backend │ │ └── hidden_contests.json │ │ └── courses │ │ └── boot_camp_for_beginners.json ├── serve.json ├── src │ ├── App.tsx │ ├── api │ │ ├── APIClient.ts │ │ ├── InternalAPIClient.ts │ │ └── index.ts │ ├── components │ │ ├── CalendarHeatmap.tsx │ │ ├── ContestLink.test.tsx │ │ ├── ContestLink.tsx │ │ ├── DifficultyCircle.tsx │ │ ├── GoogleCalendarButton.tsx │ │ ├── HelpBadgeModal.tsx │ │ ├── HelpBadgeTooltip.tsx │ │ ├── ListPaginationPanel.tsx │ │ ├── NavigationBar.tsx │ │ ├── NewTabLink.tsx │ │ ├── ProblemLink.tsx │ │ ├── ProblemSearchBox.tsx │ │ ├── ProblemSetGenerator.tsx │ │ ├── Problempoint.tsx │ │ ├── Ranking.tsx │ │ ├── SinglePieChart.tsx │ │ ├── SubmissionListTable.tsx │ │ ├── SubmitTimespan.tsx │ │ ├── ThemeProvider.tsx │ │ ├── ThemeSelector.tsx │ │ ├── Timer.tsx │ │ ├── TopcoderLikeCircle.tsx │ │ ├── TweetButton.tsx │ │ ├── UserNameLabel.tsx │ │ └── UserSearchBar.tsx │ ├── database │ │ ├── SubmissionsDB.ts │ │ └── index.ts │ ├── index.tsx │ ├── interfaces │ │ ├── Contest.ts │ │ ├── ContestParticipation.ts │ │ ├── Course.ts │ │ ├── MergedProblem.ts │ │ ├── Problem.ts │ │ ├── ProblemModel.ts │ │ ├── RankingEntry.ts │ │ ├── Status.ts │ │ ├── Submission.ts │ │ ├── UserRankEntry.ts │ │ └── index.test.ts │ ├── pages │ │ ├── ACRanking.tsx │ │ ├── FastestRanking.tsx │ │ ├── FirstRanking.tsx │ │ ├── Internal │ │ │ ├── ApiUrl.ts │ │ │ ├── MyAccountPage │ │ │ │ ├── ApiClient.ts │ │ │ │ ├── MyContestList.tsx │ │ │ │ ├── ResetProgress.tsx │ │ │ │ ├── UserIdUpdate.tsx │ │ │ │ └── index.tsx │ │ │ ├── ProblemList │ │ │ │ ├── ApiClient.ts │ │ │ │ └── SingleProblemList.tsx │ │ │ ├── UserProblemListPage.tsx │ │ │ ├── VirtualContest │ │ │ │ ├── ApiClient.ts │ │ │ │ ├── ContestConfig.tsx │ │ │ │ ├── ContestCreatePage.tsx │ │ │ │ ├── ContestUpdatePage.tsx │ │ │ │ ├── DraggableContestConfigProblemTable │ │ │ │ │ ├── ContestCell.tsx │ │ │ │ │ ├── DeleteCell.tsx │ │ │ │ │ ├── DifficultyCell.tsx │ │ │ │ │ ├── PointCell.tsx │ │ │ │ │ ├── ProblemCell.tsx │ │ │ │ │ ├── TableHeader.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── RecentContestList.tsx │ │ │ │ └── ShowContest │ │ │ │ │ ├── ContestTable.tsx │ │ │ │ │ ├── ContestTableRow.tsx │ │ │ │ │ ├── FirstAcceptanceCell.tsx │ │ │ │ │ ├── FirstAcceptanceRow.tsx │ │ │ │ │ ├── LockoutContestTable │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── ResultCalcUtil.test.ts │ │ │ │ │ ├── ResultCalcUtil.ts │ │ │ │ │ ├── ScoreCell.tsx │ │ │ │ │ ├── TrainingContestTable │ │ │ │ │ ├── SmallScoreCell.tsx │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── util.ts │ │ │ ├── VirtualContestTable.tsx │ │ │ └── types.ts │ │ ├── LanguageOwners.tsx │ │ ├── ListPage │ │ │ ├── DifficultyTable.tsx │ │ │ ├── ListTable.tsx │ │ │ ├── ProblemList.tsx │ │ │ ├── SmallTable.test.ts │ │ │ ├── SmallTable.tsx │ │ │ └── index.tsx │ │ ├── RecentSubmissions.tsx │ │ ├── ShortRanking.tsx │ │ ├── StreakRanking.tsx │ │ ├── SumRanking.tsx │ │ ├── TablePage │ │ │ ├── AtCoderRegularTable.tsx │ │ │ ├── ContestTable.test.tsx │ │ │ ├── ContestTable.tsx │ │ │ ├── Options.tsx │ │ │ ├── TableTab.tsx │ │ │ └── index.tsx │ │ ├── TrainingPage │ │ │ ├── LoginAdvice.tsx │ │ │ ├── SingleCourseView.tsx │ │ │ ├── TrainingList.tsx │ │ │ └── index.tsx │ │ └── UserPage │ │ │ ├── AchievementBlock │ │ │ └── index.tsx │ │ │ ├── CategoryPieChart │ │ │ ├── index.test.ts │ │ │ └── index.tsx │ │ │ ├── DifficultyPieChart │ │ │ └── index.tsx │ │ │ ├── LanguageCount.tsx │ │ │ ├── PieChartBlock │ │ │ ├── SmallPieChart.tsx │ │ │ └── index.tsx │ │ │ ├── ProgressChartBlock │ │ │ ├── ClimbingAreaChart.tsx │ │ │ ├── ClimbingLineChart.tsx │ │ │ ├── DailyEffortBarChart.tsx │ │ │ ├── DailyEffortStackedBarChart.tsx │ │ │ ├── DailyEffortTooltip.tsx │ │ │ ├── FilteringHeatmap.test.tsx │ │ │ ├── FilteringHeatmap.tsx │ │ │ ├── TeeChart.tsx │ │ │ └── index.tsx │ │ │ ├── Recommendations │ │ │ ├── RecommendController.tsx │ │ │ ├── RecommendProblems.ts │ │ │ └── index.tsx │ │ │ ├── Submissions.tsx │ │ │ ├── TrophyBlock │ │ │ ├── ACCountTrophyGenerator.ts │ │ │ ├── ACProblemsTrophyGenerator.ts │ │ │ ├── CompleteContestTrophyGenerator.ts │ │ │ ├── StreakTrophyGenerator.ts │ │ │ ├── Trophy.ts │ │ │ └── index.tsx │ │ │ ├── UserUtils.test.ts │ │ │ ├── UserUtils.ts │ │ │ └── index.tsx │ ├── react-app-env.d.ts │ ├── setupProxy.js │ ├── style │ │ ├── _common.scss │ │ ├── _custom.scss │ │ ├── _react-bs-table.scss │ │ ├── _theme-dark.scss │ │ ├── _theme-light.scss │ │ ├── _theme-purple.scss │ │ ├── index.scss │ │ └── theme.ts │ └── utils │ │ ├── Api.tsx │ │ ├── Chunk.ts │ │ ├── ContestClassifier.test.ts │ │ ├── ContestClassifier.ts │ │ ├── DateUtil.test.ts │ │ ├── DateUtil.ts │ │ ├── GroupBy.test.ts │ │ ├── GroupBy.ts │ │ ├── ImmutableMigration.ts │ │ ├── LanguageNormalizer.test.ts │ │ ├── LanguageNormalizer.ts │ │ ├── LastSolvedTime.test.ts │ │ ├── LastSolvedTime.ts │ │ ├── LikeContestUtils.test.ts │ │ ├── LikeContestUtils.ts │ │ ├── LocalStorage.tsx │ │ ├── ProblemModelUtil.ts │ │ ├── ProblemSelection.ts │ │ ├── QueryString.ts │ │ ├── RatingInfo.ts │ │ ├── RatingSystem.ts │ │ ├── RouterPath.ts │ │ ├── StaticDataStorage.ts │ │ ├── StreakCounter.ts │ │ ├── TableColor.ts │ │ ├── TypeUtils.ts │ │ ├── Url.tsx │ │ ├── UserState.ts │ │ ├── index.test.ts │ │ └── index.ts ├── tsconfig.json └── yarn.lock ├── config └── database-definition.sql ├── doc ├── api.md ├── faq_en.md └── faq_ja.md ├── docker-compose.yml ├── guide ├── .gitignore ├── README.md ├── book.toml └── src │ ├── SUMMARY.md │ ├── en │ ├── README.md │ ├── find_problems.md │ ├── for_developer.md │ ├── index.md │ ├── misc.md │ ├── problem_list.md │ ├── progress.md │ ├── recommend.md │ ├── reset_progress.md │ ├── training.md │ └── virtual_contest.md │ ├── index.md │ └── ja │ ├── README.md │ ├── find_problems.md │ ├── for_developer.md │ ├── index.md │ ├── misc.md │ ├── problem_list.md │ ├── progress.md │ ├── recommend.md │ ├── reset_progress.md │ ├── training.md │ └── virtual_contest.md ├── lambda-functions ├── official-api-proxy │ ├── build.sh │ └── function.py └── time-estimator │ ├── .gitignore │ ├── build.sh │ ├── compare.py │ ├── delete.py │ ├── function.py │ ├── local_generate.py │ ├── problem-models.json │ └── rating.py └── screenshot.png /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [kenkoooo] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.idea 2 | **/*.iml 3 | **/target/ 4 | **/env/ 5 | **/*.log 6 | **/node_modules/ 7 | **/dist/ 8 | **/env/ 9 | **/coverage/ 10 | **/.python-version 11 | **/package-lock.json 12 | **/.vscode 13 | **/cypress/screenshots 14 | **/cypress/videos 15 | **/serve 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - postgresql 3 | - docker 4 | matrix: 5 | include: 6 | - language: rust 7 | os: linux 8 | cache: cargo 9 | before_install: 10 | - cd atcoder-problems-backend/ 11 | before_script: 12 | - psql -c "CREATE USER kenkoooo PASSWORD 'pass';" -U postgres 13 | - psql -c "CREATE DATABASE test OWNER kenkoooo ENCODING utf8;" -U postgres 14 | script: 15 | - cargo clean 16 | - cargo build 17 | - RUST_BACKTRACE=1 cargo test --workspace -- --test-threads=1 18 | 19 | - language: node_js 20 | node_js: node 21 | addons: 22 | apt: 23 | packages: 24 | - libgconf-2-4 25 | cache: 26 | npm: true 27 | directories: 28 | - ~/.cache 29 | before_script: 30 | - cd atcoder-problems-frontend/ 31 | script: 32 | - npm install yarn -g 33 | - yarn 34 | - yarn build 35 | - yarn test 36 | - yarn lint 37 | - yarn prepare-ci 38 | - yarn start:ci & 39 | - yarn cy:run 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 kenkoooo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: 3 | @awk -F ':|##' '/^[^\t].+?:.*?##/ { printf "\033[36m%-22s\033[0m %s\n", $$1, $$NF }' $(MAKEFILE_LIST) 4 | 5 | .PHONY: up 6 | up: 7 | docker compose up 8 | 9 | .PHONY: down 10 | down: 11 | docker compose down 12 | 13 | .PHONY: test/backend 14 | test/backend: 15 | docker compose exec backend-development cargo test --workspace --no-fail-fast -- --test-threads=1 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AtCoder Problems 2 | 3 | ![CI](https://github.com/kenkoooo/AtCoderProblems/workflows/CI/badge.svg) 4 | 5 | [AtCoder Problems](https://kenkoooo.com/atcoder/) is a web application to help AtCoder users to solve problems and manage progress more efficiently. 6 | 7 | ![screenshot](./screenshot.png) 8 | 9 | # Documents 10 | - [Front-end web application](./atcoder-problems-frontend/README.md) 11 | - [Back-end server application](./atcoder-problems-backend/README.md) 12 | - [API / Datasets](./doc/api.md) 13 | - [FAQ (en)](./doc/faq_en.md) / [FAQ (ja)](./doc/faq_ja.md) 14 | -------------------------------------------------------------------------------- /atcoder-problems-backend/.dockerignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | **/target/ 3 | -------------------------------------------------------------------------------- /atcoder-problems-backend/.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | cobertura.xml 3 | -------------------------------------------------------------------------------- /atcoder-problems-backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atcoder-problems-backend" 3 | version = "0.1.0" 4 | authors = ["kenkoooo "] 5 | edition = "2021" 6 | publish = false 7 | 8 | [dependencies] 9 | # Logging 10 | log = "0.4" 11 | fern = "0.6.1" 12 | 13 | rand = "0.8.5" 14 | chrono = "0.4" 15 | rust-s3 = { version = "0.32.3", features = ["no-verify-ssl"] } 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1.0" 18 | 19 | # SQL 20 | sql-client = { path = "./sql-client" } 21 | 22 | # Scraping 23 | atcoder-client = { path = "./atcoder-client" } 24 | 25 | # Web framework 26 | actix-web = "4.2.1" 27 | actix-service = "2.0.2" 28 | reqwest = { version = "0.11", features = ["json"] } 29 | 30 | async-trait = "0.1" 31 | 32 | anyhow = "1.0" 33 | futures-util = "0.3.25" 34 | 35 | [dev-dependencies] 36 | httpmock = "0.6.6" 37 | 38 | [workspace] 39 | members = ["sql-client", "atcoder-client"] 40 | -------------------------------------------------------------------------------- /atcoder-problems-backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Using the official cargo-chef image 2 | FROM lukemathwalker/cargo-chef:latest-rust-1.69.0 AS chef 3 | WORKDIR /app 4 | 5 | FROM chef AS planner 6 | COPY . . 7 | RUN cargo chef prepare --recipe-path recipe.json 8 | 9 | FROM chef AS builder 10 | COPY --from=planner /app/recipe.json recipe.json 11 | RUN cargo chef cook --release --recipe-path recipe.json 12 | 13 | COPY . . 14 | RUN cargo build --release 15 | 16 | # プロダクション用の Docker イメージをビルドする 17 | FROM debian:bullseye AS production 18 | COPY --from=builder /app/target/release/batch_update /usr/bin/batch_update 19 | COPY --from=builder /app/target/release/crawl_all_submissions /usr/bin/crawl_all_submissions 20 | COPY --from=builder /app/target/release/crawl_for_virtual_contests /usr/bin/crawl_for_virtual_contests 21 | COPY --from=builder /app/target/release/crawl_from_new_contests /usr/bin/crawl_from_new_contests 22 | COPY --from=builder /app/target/release/crawl_problems /usr/bin/crawl_problems 23 | COPY --from=builder /app/target/release/crawl_recent_submissions /usr/bin/crawl_recent_submissions 24 | COPY --from=builder /app/target/release/crawl_whole_contest /usr/bin/crawl_whole_contest 25 | COPY --from=builder /app/target/release/delta_update /usr/bin/delta_update 26 | COPY --from=builder /app/target/release/dump_json /usr/bin/dump_json 27 | COPY --from=builder /app/target/release/fix_invalid_submissions /usr/bin/fix_invalid_submissions 28 | COPY --from=builder /app/target/release/run_server /usr/bin/run_server 29 | 30 | RUN apt-get update && apt-get install -y awscli postgresql-client 31 | ADD ./scripts/sql-backup.sh /usr/bin/sql-backup.sh 32 | -------------------------------------------------------------------------------- /atcoder-problems-backend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM rust:1.63.0 AS development 2 | RUN rustup component add rustfmt 3 | RUN rustup component add clippy 4 | 5 | # Install cargo-watch 6 | RUN wget "https://github.com/watchexec/cargo-watch/releases/download/v8.4.0/cargo-watch-v8.4.0-$(uname -m)-unknown-linux-gnu.tar.xz" \ 7 | && tar -xf "cargo-watch-v8.4.0-$(uname -m)-unknown-linux-gnu.tar.xz" \ 8 | && cp "cargo-watch-v8.4.0-$(uname -m)-unknown-linux-gnu/cargo-watch" /usr/local/bin/ \ 9 | && rm -rf "cargo-watch-v8.4.0-$(uname -m)-unknown-linux-gnu" 10 | -------------------------------------------------------------------------------- /atcoder-problems-backend/atcoder-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "atcoder-client" 3 | version = "0.1.1" 4 | authors = ["kenkoooo "] 5 | edition = "2021" 6 | publish = false 7 | 8 | [dependencies] 9 | reqwest = { version = "0.11", features = ["json", "gzip", "cookies"] } 10 | scraper = { version = "0.16", default-features = false } 11 | chrono = "0.4" 12 | regex = "1" 13 | serde = { version = "1.0", features = ["derive"] } 14 | serde_json = "1.0" 15 | anyhow = "1.0" 16 | log = "0.4" 17 | 18 | [dev-dependencies] 19 | tokio = { version = "1.23", default-features = false, features = ["macros"] } 20 | -------------------------------------------------------------------------------- /atcoder-problems-backend/atcoder-client/src/atcoder.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod contest; 3 | mod problem; 4 | mod submission; 5 | mod types; 6 | 7 | pub use client::AtCoderClient; 8 | pub use types::{ 9 | AtCoderContest, AtCoderProblem, AtCoderSubmission, AtCoderSubmissionListResponse, 10 | ContestTypeSpecifier, 11 | }; 12 | -------------------------------------------------------------------------------- /atcoder-problems-backend/atcoder-client/src/atcoder/types.rs: -------------------------------------------------------------------------------- 1 | use crate::util::Problem; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | pub enum ContestTypeSpecifier { 5 | Normal { page: u32 }, 6 | Permanent, 7 | Hidden, 8 | } 9 | 10 | pub struct AtCoderSubmissionListResponse { 11 | pub max_page: u32, 12 | pub submissions: Vec, 13 | } 14 | 15 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 16 | pub struct AtCoderContest { 17 | pub id: String, 18 | pub start_epoch_second: u64, 19 | pub duration_second: u64, 20 | pub title: String, 21 | pub rate_change: String, 22 | } 23 | 24 | #[derive(Clone, Debug, PartialEq)] 25 | pub struct AtCoderSubmission { 26 | pub id: u64, 27 | pub epoch_second: u64, 28 | pub problem_id: String, 29 | pub contest_id: String, 30 | pub user_id: String, 31 | pub language: String, 32 | pub point: f64, 33 | pub length: u64, 34 | pub result: String, 35 | pub execution_time: Option, 36 | } 37 | 38 | #[derive(Clone, Debug, PartialEq, Eq)] 39 | pub struct AtCoderProblem { 40 | pub id: String, 41 | pub title: String, 42 | pub position: String, 43 | pub contest_id: String, 44 | } 45 | 46 | impl Problem for AtCoderProblem { 47 | fn url(&self) -> String { 48 | format!( 49 | "https://atcoder.jp/contests/{}/tasks/{}", 50 | self.contest_id, self.id 51 | ) 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::*; 58 | #[test] 59 | fn test_url() { 60 | let problem = AtCoderProblem { 61 | id: "arc102_c".to_string(), 62 | title: "".to_string(), 63 | position: "".to_string(), 64 | contest_id: "arc102".to_string(), 65 | }; 66 | assert_eq!( 67 | "https://atcoder.jp/contests/arc102/tasks/arc102_c", 68 | problem.url() 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /atcoder-problems-backend/atcoder-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod atcoder; 2 | pub use atcoder::{ 3 | AtCoderClient, AtCoderContest, AtCoderProblem, AtCoderSubmission, 4 | AtCoderSubmissionListResponse, ContestTypeSpecifier, 5 | }; 6 | 7 | pub(crate) mod util; 8 | -------------------------------------------------------------------------------- /atcoder-problems-backend/atcoder-client/src/util.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Context, Result}; 2 | 3 | use reqwest::Client; 4 | use serde::de::DeserializeOwned; 5 | 6 | pub(crate) async fn get_html(url: &str, client: &Client) -> Result<(String, reqwest::StatusCode)> { 7 | let response = client 8 | .get(url) 9 | .header("accept", "text/html") 10 | .send() 11 | .await 12 | .map_err(|e| anyhow!("Connection error: {:?}", e))?; 13 | let status = response.status(); 14 | if !status.is_success() { 15 | log::error!("{:?}", response); 16 | } 17 | let body = response 18 | .text() 19 | .await 20 | .map_err(|e| anyhow!("Failed to parse HTTP body: {:?}", e))?; 21 | Ok((body, status)) 22 | } 23 | 24 | pub(crate) async fn get_json(url: &str, client: &Client) -> Result { 25 | client 26 | .get(url) 27 | .header("accept", "application/json") 28 | .send() 29 | .await 30 | .with_context(|| format!("Failed to get json from {}", url))? 31 | .json::() 32 | .await 33 | .with_context(|| format!("Failed to parse json from {}", url)) 34 | } 35 | 36 | pub trait Problem { 37 | fn url(&self) -> String; 38 | } 39 | -------------------------------------------------------------------------------- /atcoder-problems-backend/scripts/sql-backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | psql ${SQL_URL} -c "\copy submissions TO './submissions.csv' WITH (FORMAT CSV, HEADER)" 4 | gzip submissions.csv 5 | aws s3 cp submissions.csv.gz s3://kenkoooo/submissions.csv.gz --acl public-read 6 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sql-client" 3 | version = "0.1.0" 4 | authors = ["kenkoooo "] 5 | edition = "2021" 6 | publish = false 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | sqlx = { version = "0.6.2", features = ["postgres", "runtime-tokio-rustls"] } 12 | async-trait = "0.1" 13 | serde = { version = "1.0", features = ["derive"] } 14 | uuid = { version = "1.1", features = ["serde", "v4"] } 15 | anyhow = "1.0" 16 | tokio = { version = "1.23", features = ["macros"] } 17 | regex = "1" 18 | chrono = "0.4" 19 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/src/contest_problem.rs: -------------------------------------------------------------------------------- 1 | use crate::models::ContestProblem; 2 | use crate::PgPool; 3 | use anyhow::Result; 4 | use async_trait::async_trait; 5 | 6 | #[async_trait] 7 | pub trait ContestProblemClient { 8 | async fn insert_contest_problem(&self, contest_problems: &[ContestProblem]) -> Result<()>; 9 | async fn load_contest_problem(&self) -> Result>; 10 | } 11 | 12 | #[async_trait] 13 | impl ContestProblemClient for PgPool { 14 | async fn insert_contest_problem(&self, contest_problems: &[ContestProblem]) -> Result<()> { 15 | let contest_ids: Vec<&str> = contest_problems 16 | .iter() 17 | .map(|c| c.contest_id.as_str()) 18 | .collect(); 19 | let problem_ids: Vec<&str> = contest_problems 20 | .iter() 21 | .map(|c| c.problem_id.as_str()) 22 | .collect(); 23 | let problem_indexes: Vec<&str> = contest_problems 24 | .iter() 25 | .map(|c| c.problem_index.as_str()) 26 | .collect(); 27 | 28 | sqlx::query( 29 | r" 30 | INSERT INTO contest_problem (contest_id, problem_id, problem_index) 31 | VALUES ( 32 | UNNEST($1::VARCHAR(255)[]), 33 | UNNEST($2::VARCHAR(255)[]), 34 | UNNEST($3::VARCHAR(255)[]) 35 | ) 36 | ON CONFLICT DO NOTHING 37 | ", 38 | ) 39 | .bind(contest_ids) 40 | .bind(problem_ids) 41 | .bind(problem_indexes) 42 | .execute(self) 43 | .await?; 44 | 45 | Ok(()) 46 | } 47 | 48 | async fn load_contest_problem(&self) -> Result> { 49 | let problems = 50 | sqlx::query_as("SELECT contest_id, problem_id, problem_index FROM contest_problem") 51 | .fetch_all(self) 52 | .await?; 53 | 54 | Ok(problems) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/src/internal/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod problem_list_manager; 2 | pub mod progress_reset_manager; 3 | pub mod user_manager; 4 | pub mod virtual_contest_manager; 5 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/src/internal/user_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::PgPool; 2 | use anyhow::Result; 3 | use async_trait::async_trait; 4 | use serde::Serialize; 5 | 6 | #[derive(Serialize, Debug, PartialEq, Eq, sqlx::FromRow)] 7 | pub struct InternalUserInfo { 8 | pub internal_user_id: String, 9 | pub atcoder_user_id: Option, 10 | } 11 | 12 | #[async_trait] 13 | pub trait UserManager { 14 | async fn register_user(&self, internal_user_id: &str) -> Result<()>; 15 | async fn update_internal_user_info( 16 | &self, 17 | internal_user_id: &str, 18 | atcoder_user_id: &str, 19 | ) -> Result<()>; 20 | async fn get_internal_user_info(&self, internal_user_id: &str) -> Result; 21 | } 22 | 23 | #[async_trait] 24 | impl UserManager for PgPool { 25 | async fn register_user(&self, internal_user_id: &str) -> Result<()> { 26 | sqlx::query( 27 | r" 28 | INSERT INTO internal_users (internal_user_id) 29 | VALUES ($1) 30 | ON CONFLICT DO NOTHING 31 | ", 32 | ) 33 | .bind(internal_user_id) 34 | .execute(self) 35 | .await?; 36 | Ok(()) 37 | } 38 | 39 | async fn update_internal_user_info( 40 | &self, 41 | internal_user_id: &str, 42 | atcoder_user_id: &str, 43 | ) -> Result<()> { 44 | sqlx::query( 45 | r" 46 | UPDATE internal_users 47 | SET atcoder_user_id = $1 48 | WHERE internal_user_id = $2 49 | ", 50 | ) 51 | .bind(atcoder_user_id) 52 | .bind(internal_user_id) 53 | .execute(self) 54 | .await?; 55 | Ok(()) 56 | } 57 | 58 | async fn get_internal_user_info(&self, internal_user_id: &str) -> Result { 59 | let res = sqlx::query_as( 60 | r" 61 | SELECT internal_user_id, atcoder_user_id 62 | FROM internal_users 63 | WHERE internal_user_id = $1 64 | ", 65 | ) 66 | .bind(internal_user_id) 67 | .fetch_one(self) 68 | .await?; 69 | Ok(res) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::time::Duration; 3 | 4 | pub mod accepted_count; 5 | pub mod contest_problem; 6 | pub mod internal; 7 | pub mod language_count; 8 | pub mod models; 9 | pub mod problem_info; 10 | pub mod problems_submissions; 11 | pub mod rated_point_sum; 12 | pub mod simple_client; 13 | pub mod streak; 14 | pub mod submission_client; 15 | 16 | pub use sqlx::postgres::{PgPool, PgPoolOptions, PgRow}; 17 | pub use sqlx::{query, Row}; 18 | 19 | const FIRST_AGC_EPOCH_SECOND: i64 = 1_468_670_400; 20 | const UNRATED_STATE: &str = "-"; 21 | const MAX_INSERT_ROWS: usize = 10_000; 22 | 23 | pub async fn initialize_pool>(database_url: S) -> Result { 24 | let pool = PgPoolOptions::new() 25 | .max_lifetime(Some(Duration::from_secs(60 * 5))) 26 | .max_connections(15) 27 | .connect(database_url.as_ref()) 28 | .await?; 29 | Ok(pool) 30 | } 31 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Debug, Eq, PartialEq, Serialize, sqlx::FromRow)] 4 | pub struct Contest { 5 | pub id: String, 6 | pub start_epoch_second: i64, 7 | pub duration_second: i64, 8 | pub title: String, 9 | pub rate_change: String, 10 | } 11 | 12 | #[derive(Debug, Eq, PartialEq, Serialize, sqlx::FromRow)] 13 | pub struct Problem { 14 | pub id: String, 15 | pub contest_id: String, 16 | pub problem_index: String, 17 | pub name: String, 18 | pub title: String, 19 | } 20 | 21 | #[derive(Debug, Clone, PartialEq, Serialize, Default, Deserialize, sqlx::FromRow)] 22 | pub struct Submission { 23 | pub id: i64, 24 | pub epoch_second: i64, 25 | pub problem_id: String, 26 | pub contest_id: String, 27 | pub user_id: String, 28 | pub language: String, 29 | pub point: f64, 30 | pub length: i32, 31 | pub result: String, 32 | pub execution_time: Option, 33 | } 34 | 35 | #[derive(Debug, Eq, PartialEq, Serialize, sqlx::FromRow)] 36 | pub struct UserLanguageCount { 37 | pub user_id: String, 38 | 39 | #[serde(rename = "language")] 40 | pub simplified_language: String, 41 | 42 | #[serde(rename = "count")] 43 | pub problem_count: i32, 44 | } 45 | 46 | #[derive(Debug, Eq, PartialEq, Serialize, sqlx::FromRow)] 47 | pub struct UserLanguageCountRank { 48 | pub user_id: String, 49 | 50 | #[serde(rename = "language")] 51 | pub simplified_language: String, 52 | 53 | pub rank: i64, 54 | } 55 | 56 | #[derive(Debug, Eq, PartialEq, Serialize, sqlx::FromRow)] 57 | pub struct UserProblemCount { 58 | pub user_id: String, 59 | pub problem_count: i32, 60 | } 61 | 62 | #[derive(Debug, Serialize, sqlx::FromRow)] 63 | pub struct UserSum { 64 | pub user_id: String, 65 | pub point_sum: i64, 66 | } 67 | 68 | #[derive(PartialEq, Debug, Serialize, sqlx::FromRow)] 69 | pub struct ContestProblem { 70 | pub contest_id: String, 71 | pub problem_id: String, 72 | pub problem_index: String, 73 | } 74 | 75 | #[derive(PartialEq, Debug, Serialize, sqlx::FromRow)] 76 | pub struct UserStreak { 77 | pub user_id: String, 78 | pub streak: i64, 79 | } 80 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/src/problem_info.rs: -------------------------------------------------------------------------------- 1 | use crate::{PgPool, FIRST_AGC_EPOCH_SECOND}; 2 | use anyhow::Result; 3 | use async_trait::async_trait; 4 | 5 | #[async_trait] 6 | pub trait ProblemInfoUpdater { 7 | async fn update_solver_count(&self) -> Result<()>; 8 | async fn update_problem_points(&self) -> Result<()>; 9 | } 10 | 11 | #[async_trait] 12 | impl ProblemInfoUpdater for PgPool { 13 | async fn update_solver_count(&self) -> Result<()> { 14 | sqlx::query( 15 | r" 16 | INSERT INTO solver (user_count, problem_id) 17 | SELECT COUNT(DISTINCT(user_id)), problem_id 18 | FROM submissions 19 | WHERE result = 'AC' 20 | GROUP BY problem_id 21 | ON CONFLICT (problem_id) DO UPDATE 22 | SET user_count = EXCLUDED.user_count; 23 | ", 24 | ) 25 | .execute(self) 26 | .await?; 27 | Ok(()) 28 | } 29 | 30 | /// 各問題の点数を計算して更新する。 31 | /// 32 | /// ある問題への提出のうち、 __コンテスト開始後の提出__ における最も大きい得点がその問題の点数となる。 33 | /// 34 | /// コンテスト開始前、writerなどが仮の点数が付けられている問題をACすることがあり、 35 | /// 仮の点数がコンテストでの正式な点数より大きかった場合には、仮の点数がAtCoder Problemsでの正式な点数とされてしまう。 36 | /// これを防ぐために、コンテスト開始前の提出は点数計算で考慮しないようにしている。 37 | /// 38 | /// 「コンテスト開始前にwriterがACしているが、コンテスト開始以降に一人もACできていない」場合は点数が 39 | /// 計算されないという問題があるが、現時点ではその問題は発生していないため対応は保留されている。 40 | async fn update_problem_points(&self) -> Result<()> { 41 | sqlx::query( 42 | r" 43 | INSERT INTO points (problem_id, point) 44 | SELECT submissions.problem_id, MAX(submissions.point) 45 | FROM submissions 46 | INNER JOIN contests ON contests.id = submissions.contest_id 47 | WHERE contests.start_epoch_second >= $1 48 | AND submissions.epoch_second >= contests.start_epoch_second 49 | AND contests.rate_change != '-' 50 | GROUP BY submissions.problem_id 51 | ON CONFLICT (problem_id) DO UPDATE 52 | SET point = EXCLUDED.point; 53 | ", 54 | ) 55 | .bind(FIRST_AGC_EPOCH_SECOND) 56 | .execute(self) 57 | .await?; 58 | Ok(()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/src/problems_submissions.rs: -------------------------------------------------------------------------------- 1 | use crate::PgPool; 2 | use anyhow::Result; 3 | use async_trait::async_trait; 4 | 5 | #[async_trait] 6 | pub trait ProblemsSubmissionUpdater { 7 | async fn update_submissions_of_problems(&self) -> Result<()>; 8 | } 9 | 10 | #[async_trait] 11 | impl ProblemsSubmissionUpdater for PgPool { 12 | async fn update_submissions_of_problems(&self) -> Result<()> { 13 | let first_sql = generate_query("first", "id"); 14 | let fastest_sql = generate_query("fastest", "execution_time"); 15 | let shortest_sql = generate_query("shortest", "length"); 16 | 17 | tokio::try_join!( 18 | sqlx::query(&first_sql).execute(self), 19 | sqlx::query(&fastest_sql).execute(self), 20 | sqlx::query(&shortest_sql).execute(self), 21 | )?; 22 | 23 | Ok(()) 24 | } 25 | } 26 | 27 | fn generate_query(table: &str, column: &str) -> String { 28 | format!( 29 | r" 30 | INSERT INTO {table} 31 | (submission_id, problem_id, contest_id) 32 | SELECT id, problem_id, contest_id FROM submissions 33 | WHERE id IN 34 | ( 35 | SELECT MIN(submissions.id) FROM submissions 36 | LEFT JOIN contests ON contests.id=contest_id 37 | WHERE result='AC' 38 | AND contests.start_epoch_second < submissions.epoch_second 39 | AND (problem_id, submissions.{column}) IN 40 | ( 41 | SELECT problem_id, MIN(submissions.{column}) FROM submissions 42 | LEFT JOIN contests ON contests.id=contest_id 43 | WHERE result='AC' 44 | AND contests.start_epoch_second < submissions.epoch_second 45 | GROUP BY problem_id 46 | ) 47 | GROUP BY problem_id 48 | ) 49 | ON CONFLICT (problem_id) 50 | DO UPDATE SET 51 | contest_id=EXCLUDED.contest_id, 52 | problem_id=EXCLUDED.problem_id, 53 | submission_id=EXCLUDED.submission_id;", 54 | table = table, 55 | column = column 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/tests/test_contest_problem.rs: -------------------------------------------------------------------------------- 1 | use sql_client::contest_problem::ContestProblemClient; 2 | use sql_client::models::ContestProblem; 3 | 4 | mod utils; 5 | 6 | fn create_problem(id: i32) -> ContestProblem { 7 | ContestProblem { 8 | contest_id: format!("contest{}", id), 9 | problem_id: format!("problem{}", id), 10 | problem_index: format!("{}", id), 11 | } 12 | } 13 | 14 | #[tokio::test] 15 | async fn test_contest_problem() { 16 | let pool = utils::initialize_and_connect_to_test_sql().await; 17 | assert!(pool.load_contest_problem().await.unwrap().is_empty()); 18 | 19 | pool.insert_contest_problem(&[create_problem(1), create_problem(2)]) 20 | .await 21 | .unwrap(); 22 | assert_eq!( 23 | pool.load_contest_problem().await.unwrap(), 24 | vec![create_problem(1), create_problem(2)] 25 | ); 26 | pool.insert_contest_problem(&[create_problem(1)]) 27 | .await 28 | .unwrap(); 29 | assert_eq!( 30 | pool.load_contest_problem().await.unwrap(), 31 | vec![create_problem(1), create_problem(2)] 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/tests/test_simple_client.rs: -------------------------------------------------------------------------------- 1 | use sql_client::models::{Contest, Problem}; 2 | use sql_client::simple_client::SimpleClient; 3 | 4 | mod utils; 5 | 6 | #[tokio::test] 7 | async fn test_insert_contests() { 8 | let pool = utils::initialize_and_connect_to_test_sql().await; 9 | assert!(pool.load_contests().await.unwrap().is_empty()); 10 | pool.insert_contests(&[Contest { 11 | id: "contest1".to_string(), 12 | start_epoch_second: 0, 13 | duration_second: 0, 14 | title: "".to_string(), 15 | rate_change: "".to_string(), 16 | }]) 17 | .await 18 | .unwrap(); 19 | 20 | let contests = pool.load_contests().await.unwrap(); 21 | assert_eq!(contests[0].id, "contest1"); 22 | 23 | pool.insert_contests(&[Contest { 24 | id: "contest1".to_string(), 25 | start_epoch_second: 0, 26 | duration_second: 0, 27 | title: "".to_string(), 28 | rate_change: "".to_string(), 29 | }]) 30 | .await 31 | .unwrap(); 32 | } 33 | 34 | #[tokio::test] 35 | async fn test_insert_problems() { 36 | let pool = utils::initialize_and_connect_to_test_sql().await; 37 | assert!(pool.load_problems().await.unwrap().is_empty()); 38 | pool.insert_problems(&[Problem { 39 | id: "problem1".to_string(), 40 | contest_id: "".to_string(), 41 | problem_index: "".to_string(), 42 | name: "".to_string(), 43 | title: "".to_string(), 44 | }]) 45 | .await 46 | .unwrap(); 47 | 48 | let problems = pool.load_problems().await.unwrap(); 49 | assert_eq!(problems[0].id, "problem1"); 50 | 51 | pool.insert_problems(&[Problem { 52 | id: "problem1".to_string(), 53 | contest_id: "".to_string(), 54 | problem_index: "".to_string(), 55 | name: "".to_string(), 56 | title: "".to_string(), 57 | }]) 58 | .await 59 | .unwrap(); 60 | } 61 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/tests/test_user_manager.rs: -------------------------------------------------------------------------------- 1 | use sql_client::internal::user_manager::{InternalUserInfo, UserManager}; 2 | 3 | mod utils; 4 | 5 | #[tokio::test] 6 | async fn test_user_manager() { 7 | let internal_user_id = "user_id"; 8 | let atcoder_user_id = "atcoder_id"; 9 | let pool = utils::initialize_and_connect_to_test_sql().await; 10 | 11 | let get_result = pool.get_internal_user_info(internal_user_id).await; 12 | assert!( 13 | get_result.is_err(), 14 | "`get_result` for a user who has not yet been registered should be `Err`, but got `Ok`." 15 | ); 16 | 17 | let register_result = pool.register_user(internal_user_id).await; 18 | assert!( 19 | register_result.is_ok(), 20 | "`register_user` failed unexpectedly." 21 | ); 22 | 23 | let register_result = pool.register_user(internal_user_id).await; 24 | assert!( 25 | register_result.is_ok(), 26 | "Calling `register_user` twice should return `Ok`, but got `Err`." 27 | ); 28 | 29 | let get_result = pool.get_internal_user_info(internal_user_id).await.unwrap(); 30 | assert_eq!( 31 | get_result, 32 | InternalUserInfo { 33 | internal_user_id: internal_user_id.to_string(), 34 | atcoder_user_id: None, 35 | }, 36 | "`get_internal_user_info` for a user whose `atcoder_user_id` is not set returned an unexpected value." 37 | ); 38 | 39 | let update_result = pool 40 | .update_internal_user_info(internal_user_id, atcoder_user_id) 41 | .await; 42 | assert!( 43 | update_result.is_ok(), 44 | "`update_internal_user_info` failed unexpectedly." 45 | ); 46 | 47 | let get_result = pool.get_internal_user_info(internal_user_id).await.unwrap(); 48 | assert_eq!( 49 | get_result, 50 | InternalUserInfo { 51 | internal_user_id: internal_user_id.to_string(), 52 | atcoder_user_id: Some(atcoder_user_id.to_string()), 53 | }, 54 | "`get_internal_user_info` after `atcoder_user_id` was set returned an unexpected value." 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /atcoder-problems-backend/sql-client/tests/utils/mod.rs: -------------------------------------------------------------------------------- 1 | use sql_client::PgPool; 2 | use sqlx::Executor; 3 | use tokio::fs; 4 | 5 | const SQL_FILE: &str = "../../config/database-definition.sql"; 6 | const SQL_URL_ENV_KEY: &str = "SQL_URL"; 7 | 8 | #[cfg(test)] 9 | #[allow(dead_code)] 10 | pub async fn setup_internal_user(pool: &PgPool, internal_user_id: &str, atcoder_user_id: &str) { 11 | sqlx::query( 12 | r" 13 | INSERT INTO internal_users (internal_user_id, atcoder_user_id) 14 | VALUES ($1, $2) 15 | ", 16 | ) 17 | .bind(internal_user_id) 18 | .bind(atcoder_user_id) 19 | .execute(pool) 20 | .await 21 | .unwrap(); 22 | } 23 | 24 | pub async fn initialize_and_connect_to_test_sql() -> PgPool { 25 | let sql_url = std::env::var(SQL_URL_ENV_KEY).unwrap(); 26 | let pool = sql_client::initialize_pool(sql_url).await.unwrap(); 27 | initialize(&pool).await; 28 | pool 29 | } 30 | 31 | async fn initialize(pool: &PgPool) { 32 | let sql = fs::read_to_string(SQL_FILE).await.unwrap(); 33 | let mut conn = pool.acquire().await.unwrap(); 34 | conn.execute(sql.as_str()).await.unwrap(); 35 | } 36 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/bin/crawl_for_virtual_contests.rs: -------------------------------------------------------------------------------- 1 | use actix_web::rt::time; 2 | use anyhow::Result; 3 | use atcoder_client::AtCoderClient; 4 | use atcoder_problems_backend::crawler::{FixCrawler, VirtualContestCrawler}; 5 | use atcoder_problems_backend::utils::init_log_config; 6 | use chrono::Utc; 7 | use rand::{thread_rng, Rng}; 8 | use sql_client::initialize_pool; 9 | use std::{ 10 | env, 11 | time::{Duration, Instant}, 12 | }; 13 | 14 | const FIX_RANGE_SECOND: i64 = 10 * 60; 15 | 16 | async fn crawl(url: &str, rng: &mut R, username: &str, password: &str) -> Result<()> { 17 | log::info!("Start crawling..."); 18 | let pg_pool = initialize_pool(&url).await?; 19 | let client = AtCoderClient::new(username, password).await?; 20 | let mut crawler = VirtualContestCrawler::new(pg_pool.clone(), client.clone(), rng); 21 | crawler.crawl().await?; 22 | log::info!("Finished crawling"); 23 | 24 | log::info!("Starting fixing..."); 25 | let cur = Utc::now().timestamp(); 26 | let crawler = FixCrawler::new(pg_pool, client, cur - FIX_RANGE_SECOND); 27 | crawler.crawl().await?; 28 | log::info!("Finished fixing"); 29 | 30 | Ok(()) 31 | } 32 | 33 | #[actix_web::main] 34 | async fn main() { 35 | init_log_config().unwrap(); 36 | let url = env::var("SQL_URL").expect("SQL_URL must be set."); 37 | let username = env::var("ATCODER_USERNAME").expect("ATCODER_USERNAME is not set."); 38 | let password = env::var("ATCODER_PASSWORD").expect("ATCODER_PASSWORD is not set."); 39 | log::info!("Started"); 40 | 41 | let mut rng = thread_rng(); 42 | 43 | loop { 44 | log::info!("Start new loop..."); 45 | let now = Instant::now(); 46 | 47 | if let Err(e) = crawl(&url, &mut rng, &username, &password).await { 48 | log::error!("{:?}", e); 49 | } 50 | 51 | let elapsed_secs = now.elapsed().as_secs(); 52 | log::info!("Elapsed {} sec.", elapsed_secs); 53 | if elapsed_secs < 10 { 54 | let sleep_seconds = 10 - elapsed_secs; 55 | log::info!("Sleeping {} sec.", sleep_seconds); 56 | time::sleep(Duration::from_secs(sleep_seconds)).await; 57 | } 58 | 59 | log::info!("Finished a loop"); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/bin/crawl_from_new_contests.rs: -------------------------------------------------------------------------------- 1 | use actix_web::rt::time; 2 | use anyhow::Result; 3 | use atcoder_client::AtCoderClient; 4 | use atcoder_problems_backend::crawler::WholeContestCrawler; 5 | use atcoder_problems_backend::utils::init_log_config; 6 | use log::info; 7 | use sql_client::initialize_pool; 8 | use sql_client::simple_client::SimpleClient; 9 | use std::{env, time::Duration}; 10 | 11 | const NEW_CONTEST_NUM: usize = 5; 12 | 13 | async fn iteration(url: &str, username: &str, password: &str) -> Result<()> { 14 | let db = initialize_pool(&url).await?; 15 | let client = AtCoderClient::new(username, password).await?; 16 | let mut contests = db.load_contests().await?; 17 | contests.sort_by_key(|c| c.start_epoch_second); 18 | contests.reverse(); 19 | 20 | for contest in &contests[0..NEW_CONTEST_NUM] { 21 | info!("Starting {}", contest.id); 22 | let crawler = WholeContestCrawler::new(db.clone(), client.clone(), &contest.id); 23 | crawler.crawl().await?; 24 | } 25 | Ok(()) 26 | } 27 | 28 | #[actix_web::main] 29 | async fn main() { 30 | init_log_config().unwrap(); 31 | info!("Started"); 32 | let url = env::var("SQL_URL").expect("SQL_URL is not set."); 33 | let username = env::var("ATCODER_USERNAME").expect("ATCODER_USERNAME is not set."); 34 | let password = env::var("ATCODER_PASSWORD").expect("ATCODER_PASSWORD is not set."); 35 | 36 | loop { 37 | info!("Start new loop"); 38 | if let Err(e) = iteration(&url, &username, &password).await { 39 | log::error!("{:?}", e); 40 | time::sleep(Duration::from_secs(1)).await; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/bin/crawl_problems.rs: -------------------------------------------------------------------------------- 1 | use atcoder_client::AtCoderClient; 2 | use atcoder_problems_backend::crawler::ProblemCrawler; 3 | use atcoder_problems_backend::utils::init_log_config; 4 | use sql_client::initialize_pool; 5 | use std::env; 6 | 7 | #[actix_web::main] 8 | async fn main() { 9 | init_log_config().unwrap(); 10 | log::info!("Started"); 11 | let url = env::var("SQL_URL").expect("SQL_URL is not set."); 12 | let username = env::var("ATCODER_USERNAME").expect("ATCODER_USERNAME is not set."); 13 | let password = env::var("ATCODER_PASSWORD").expect("ATCODER_PASSWORD is not set."); 14 | 15 | let db = initialize_pool(&url).await.unwrap(); 16 | let client = AtCoderClient::new(&username, &password) 17 | .await 18 | .expect("AtCoder authentication failure"); 19 | let crawler = ProblemCrawler::new(db, client); 20 | crawler.crawl().await.expect("Failed to crawl"); 21 | 22 | log::info!("Finished"); 23 | } 24 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/bin/crawl_recent_submissions.rs: -------------------------------------------------------------------------------- 1 | use actix_web::rt::time; 2 | use anyhow::Result; 3 | use atcoder_client::AtCoderClient; 4 | use atcoder_problems_backend::crawler::RecentCrawler; 5 | use atcoder_problems_backend::utils::init_log_config; 6 | use sql_client::initialize_pool; 7 | use std::{env, time::Duration}; 8 | 9 | async fn crawl(url: &str, username: &str, password: &str) -> Result<()> { 10 | let db = initialize_pool(url).await?; 11 | let client = AtCoderClient::new(username, password).await?; 12 | let crawler = RecentCrawler::new(db, client); 13 | crawler.crawl().await 14 | } 15 | 16 | #[actix_web::main] 17 | async fn main() { 18 | init_log_config().unwrap(); 19 | log::info!("Started"); 20 | let url = env::var("SQL_URL").expect("SQL_URL must be set."); 21 | let username = env::var("ATCODER_USERNAME").expect("ATCODER_USERNAME is not set."); 22 | let password = env::var("ATCODER_PASSWORD").expect("ATCODER_PASSWORD is not set."); 23 | 24 | loop { 25 | log::info!("Start new loop"); 26 | if let Err(e) = crawl(&url, &username, &password).await { 27 | log::error!("{:?}", e); 28 | time::sleep(Duration::from_secs(1)).await; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/bin/crawl_whole_contest.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use atcoder_client::AtCoderClient; 3 | use atcoder_problems_backend::crawler::WholeContestCrawler; 4 | use atcoder_problems_backend::utils::init_log_config; 5 | use log::info; 6 | use sql_client::initialize_pool; 7 | use std::env; 8 | 9 | #[actix_web::main] 10 | async fn main() -> Result<()> { 11 | init_log_config()?; 12 | info!("Started"); 13 | let url = env::var("SQL_URL").expect("SQL_URL should be set as environmental variable."); 14 | let username = env::var("ATCODER_USERNAME").expect("ATCODER_USERNAME is not set."); 15 | let password = env::var("ATCODER_PASSWORD").expect("ATCODER_PASSWORD is not set."); 16 | let contest_id = env::args() 17 | .nth(1) 18 | .expect("contest_id is not set.\nUsage: cargo run --bin crawl_whole_contest "); 19 | let db = initialize_pool(&url).await?; 20 | let client = AtCoderClient::new(&username, &password).await?; 21 | let crawler = WholeContestCrawler::new(db, client, contest_id); 22 | crawler.crawl().await?; 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/bin/fix_invalid_submissions.rs: -------------------------------------------------------------------------------- 1 | use atcoder_client::AtCoderClient; 2 | use atcoder_problems_backend::crawler::FixCrawler; 3 | use atcoder_problems_backend::utils::init_log_config; 4 | use chrono::Utc; 5 | use log::info; 6 | use sql_client::initialize_pool; 7 | use std::env; 8 | 9 | const ONE_DAY: i64 = 24 * 3600; 10 | 11 | #[actix_web::main] 12 | async fn main() { 13 | init_log_config().unwrap(); 14 | info!("Started"); 15 | let url = env::var("SQL_URL").expect("SQL_URL must be set."); 16 | let username = env::var("ATCODER_USERNAME").expect("ATCODER_USERNAME is not set."); 17 | let password = env::var("ATCODER_PASSWORD").expect("ATCODER_PASSWORD is not set."); 18 | 19 | let db = initialize_pool(&url).await.unwrap(); 20 | let now = Utc::now().timestamp(); 21 | let client = AtCoderClient::new(&username, &password) 22 | .await 23 | .expect("AtCoder authentication failure"); 24 | let crawler = FixCrawler::new(db, client, now - ONE_DAY); 25 | crawler.crawl().await.expect("Failed to crawl"); 26 | info!("Finished fixing."); 27 | } 28 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/bin/run_server.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use atcoder_problems_backend::server::middleware::github_auth::GithubClient; 4 | use atcoder_problems_backend::server::run_server; 5 | use atcoder_problems_backend::utils::init_log_config; 6 | 7 | #[actix_web::main] 8 | async fn main() { 9 | init_log_config().unwrap(); 10 | let database_url = env::var("SQL_URL").expect("SQL_URL is not set."); 11 | let port = 8080; 12 | 13 | let client_id = env::var("CLIENT_ID").unwrap_or_else(|_| String::new()); 14 | let client_secret = env::var("CLIENT_SECRET").unwrap_or_else(|_| String::new()); 15 | 16 | let pg_pool = sql_client::initialize_pool(&database_url) 17 | .await 18 | .expect("Failed to initialize the connection pool"); 19 | let github = GithubClient::new( 20 | &client_id, 21 | &client_secret, 22 | "https://github.com", 23 | "https://api.github.com", 24 | ) 25 | .expect("Failed to create github client"); 26 | run_server(pg_pool, github, port) 27 | .await 28 | .expect("Failed to run server"); 29 | } 30 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/config.rs: -------------------------------------------------------------------------------- 1 | pub const BLOCKED_CONTESTS: [&str; 1] = ["practice"]; 2 | 3 | pub const BLOCKED_PROBLEMS: [&str; 47] = [ 4 | "future_contest_2021_final_b", 5 | "future_contest_2021_qual_b", 6 | "future_contest_2020_final_2_b", 7 | "future_contest_2020_qual_b", 8 | "future_contest_2019_final_b", 9 | "future_contest_2019_qual_b", 10 | "future2018career_b", 11 | "future_contest_2020_final_b", 12 | "APG4b_b", 13 | "APG4b_c", 14 | "APG4b_d", 15 | "APG4b_e", 16 | "APG4b_f", 17 | "APG4b_g", 18 | "APG4b_h", 19 | "APG4b_i", 20 | "APG4b_j", 21 | "APG4b_k", 22 | "APG4b_l", 23 | "APG4b_m", 24 | "APG4b_n", 25 | "APG4b_o", 26 | "APG4b_p", 27 | "APG4b_q", 28 | "APG4b_r", 29 | "APG4b_s", 30 | "APG4b_t", 31 | "APG4b_u", 32 | "APG4b_v", 33 | "APG4b_w", 34 | "APG4b_x", 35 | "APG4b_y", 36 | "APG4b_z", 37 | "APG4b_aa", 38 | "APG4b_ab", 39 | "APG4b_ac", 40 | "APG4b_ad", 41 | "APG4b_ae", 42 | "APG4b_af", 43 | "APG4b_ag", 44 | "APG4b_ah", 45 | "APG4b_ai", 46 | "APG4b_aj", 47 | "APG4b_ak", 48 | "APG4b_al", 49 | "APG4b_am", 50 | "APG4b_an", 51 | ]; 52 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/crawler/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::crawler::AtCoderFetcher; 2 | use anyhow::Result; 3 | use async_trait::async_trait; 4 | use atcoder_client::ContestTypeSpecifier; 5 | use sql_client::models::{Contest, ContestProblem, Problem, Submission}; 6 | 7 | pub(crate) struct MockFetcher Vec>(pub(crate) F); 8 | 9 | #[async_trait] 10 | impl AtCoderFetcher for MockFetcher 11 | where 12 | F: Fn(&str, u32) -> Vec + Send + Sync, 13 | { 14 | async fn fetch_submissions(&self, contest_id: &str, page: u32) -> (Vec, u32) { 15 | ((self.0)(contest_id, page), 0) 16 | } 17 | 18 | async fn fetch_contests(&self, _: ContestTypeSpecifier) -> Result> { 19 | unimplemented!() 20 | } 21 | 22 | async fn fetch_problems(&self, _: &str) -> Result<(Vec, Vec)> { 23 | unimplemented!() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod crawler; 3 | pub mod s3; 4 | pub mod server; 5 | pub mod utils; 6 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/s3.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use s3::bucket::Bucket; 4 | use s3::creds::Credentials; 5 | 6 | const BUCKET_NAME: &str = "kenkoooo.com"; 7 | const REGION: &str = "ap-northeast-1"; 8 | 9 | pub struct S3Client { 10 | bucket: Bucket, 11 | } 12 | 13 | impl S3Client { 14 | pub fn new() -> Result { 15 | let region = REGION.parse()?; 16 | let credentials = Credentials::from_instance_metadata()?; 17 | let bucket = Bucket::new(BUCKET_NAME, region, credentials)?; 18 | Ok(Self { bucket }) 19 | } 20 | 21 | pub async fn update(&self, data: Vec, path: &str) -> Result { 22 | log::info!("Fetching old data ..."); 23 | let old_data = self 24 | .bucket 25 | .get_object(path) 26 | .await 27 | .map(|resp| resp.into()) 28 | .unwrap_or_else(|e| { 29 | log::error!("{:?}", e); 30 | Vec::new() 31 | }); 32 | 33 | if old_data != data { 34 | log::info!("Uploading new data to {} ...", path); 35 | let resp = self 36 | .bucket 37 | .put_object_with_content_type(path, &data, "application/json;charset=utf-8") 38 | .await?; 39 | log::info!("status={}", resp.status_code()); 40 | Ok(true) 41 | } else { 42 | log::info!("No update on {}", path); 43 | Ok(false) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/endpoint/healthcheck.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{get, HttpResponse, Responder}; 2 | 3 | #[get("/healthcheck")] 4 | pub async fn get_healthcheck() -> impl Responder { 5 | HttpResponse::Ok().finish() 6 | } 7 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/endpoint/internal_api/contest/item.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{post, web, HttpResponse, Responder, Result}; 2 | use serde::Deserialize; 3 | use sql_client::{ 4 | internal::virtual_contest_manager::{VirtualContestItem, VirtualContestManager}, 5 | PgPool, 6 | }; 7 | 8 | use crate::server::{error::ApiResult, middleware::github_auth::GithubToken}; 9 | 10 | #[derive(Deserialize)] 11 | pub struct UpdateItemsQuery { 12 | contest_id: String, 13 | problems: Vec, 14 | } 15 | 16 | #[post("/internal-api/contest/item/update")] 17 | pub async fn update_items( 18 | token: web::ReqData, 19 | pool: web::Data, 20 | query: web::Json, 21 | ) -> Result { 22 | let user_id = token.id.to_string(); 23 | pool.update_items(&query.contest_id, &query.problems, &user_id) 24 | .await 25 | .map_internal_server_err()?; 26 | let response = HttpResponse::Ok().finish(); 27 | Ok(response) 28 | } 29 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/endpoint/internal_api/list/item.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{post, web, HttpResponse, Responder, Result}; 2 | use serde::Deserialize; 3 | use sql_client::{internal::problem_list_manager::ProblemListManager, PgPool}; 4 | 5 | use crate::server::{error::ApiResult, middleware::github_auth::GithubToken}; 6 | 7 | #[derive(Deserialize)] 8 | pub struct AddItemQuery { 9 | internal_list_id: String, 10 | problem_id: String, 11 | } 12 | 13 | #[post("/internal-api/list/item/add")] 14 | pub async fn add_item( 15 | query: web::Json, 16 | pool: web::Data, 17 | _: web::ReqData, 18 | ) -> Result { 19 | // TODO authorize 20 | pool.add_item(&query.internal_list_id, &query.problem_id) 21 | .await 22 | .map_internal_server_err()?; 23 | let response = HttpResponse::Ok().finish(); 24 | Ok(response) 25 | } 26 | 27 | #[derive(Deserialize)] 28 | pub struct UpdateItemQuery { 29 | internal_list_id: String, 30 | problem_id: String, 31 | memo: String, 32 | } 33 | 34 | #[post("/internal-api/list/item/update")] 35 | pub async fn update_item( 36 | query: web::Json, 37 | pool: web::Data, 38 | _: web::ReqData, 39 | ) -> Result { 40 | // TODO authorize 41 | pool.update_item(&query.internal_list_id, &query.problem_id, &query.memo) 42 | .await 43 | .map_internal_server_err()?; 44 | let response = HttpResponse::Ok().finish(); 45 | Ok(response) 46 | } 47 | 48 | #[derive(Deserialize)] 49 | pub struct DeleteItemQuery { 50 | internal_list_id: String, 51 | problem_id: String, 52 | } 53 | 54 | #[post("/internal-api/list/item/delete")] 55 | pub async fn delete_item( 56 | query: web::Json, 57 | pool: web::Data, 58 | _: web::ReqData, 59 | ) -> Result { 60 | // TODO authorize 61 | pool.delete_item(&query.internal_list_id, &query.problem_id) 62 | .await 63 | .map_internal_server_err()?; 64 | let response = HttpResponse::Ok().finish(); 65 | Ok(response) 66 | } 67 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/endpoint/internal_api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod contest; 2 | pub mod list; 3 | pub mod progress_reset; 4 | pub mod user; 5 | 6 | use actix_web::{cookie::Cookie, get, web, HttpResponse, Result}; 7 | use reqwest::header::LOCATION; 8 | use serde::Deserialize; 9 | use sql_client::{internal::user_manager::UserManager, PgPool}; 10 | 11 | use crate::server::{error::ApiResult, middleware::github_auth::GithubClient}; 12 | 13 | const REDIRECT_URL: &str = "https://kenkoooo.com/atcoder/"; 14 | #[derive(Deserialize)] 15 | pub struct Query { 16 | code: String, 17 | redirect_to: Option, 18 | } 19 | 20 | #[get("/internal-api/authorize")] 21 | pub async fn get_authorize( 22 | client: web::Data, 23 | query: web::Query, 24 | pool: web::Data, 25 | ) -> Result { 26 | let token = client 27 | .authorize(&query.code) 28 | .await 29 | .map_internal_server_err()?; 30 | let user_id = client.verify_user(&token).await.map_internal_server_err()?; 31 | pool.register_user(&user_id.id.to_string()) 32 | .await 33 | .map_internal_server_err()?; 34 | let cookie = Cookie::build("token", token).path("/").finish(); 35 | let redirect_fragment = query.redirect_to.as_deref().unwrap_or("/login/user"); 36 | let redirect_url = format!("{}#{}", REDIRECT_URL, redirect_fragment); 37 | let response = HttpResponse::Found() 38 | .insert_header((LOCATION, redirect_url)) 39 | .cookie(cookie) 40 | .finish(); 41 | Ok(response) 42 | } 43 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/endpoint/internal_api/progress_reset.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{get, post, web, HttpResponse, Result}; 2 | use serde::Deserialize; 3 | use sql_client::{internal::progress_reset_manager::ProgressResetManager, PgPool}; 4 | 5 | use crate::server::{error::ApiResult, middleware::github_auth::GithubToken}; 6 | 7 | #[get("/internal-api/progress_reset/list")] 8 | pub async fn get_progress_reset_list( 9 | token: web::ReqData, 10 | pool: web::Data, 11 | ) -> Result { 12 | let user_id = token.id.to_string(); 13 | let list = pool 14 | .get_progress_reset_list(&user_id) 15 | .await 16 | .map_internal_server_err()?; 17 | let response = HttpResponse::Ok().json(&list); 18 | Ok(response) 19 | } 20 | 21 | #[derive(Deserialize)] 22 | pub struct AddItemQuery { 23 | problem_id: String, 24 | reset_epoch_second: i64, 25 | } 26 | 27 | #[post("/internal-api/progress_reset/add")] 28 | pub async fn add_progress_reset_item( 29 | token: web::ReqData, 30 | pool: web::Data, 31 | query: web::Json, 32 | ) -> Result { 33 | let user_id = token.id.to_string(); 34 | pool.add_item(&user_id, &query.problem_id, query.reset_epoch_second) 35 | .await 36 | .map_internal_server_err()?; 37 | Ok(HttpResponse::Ok().finish()) 38 | } 39 | 40 | #[derive(Deserialize)] 41 | pub struct DeleteItemQuery { 42 | problem_id: String, 43 | } 44 | 45 | #[post("/internal-api/progress_reset/delete")] 46 | pub async fn delete_progress_reset_item( 47 | token: web::ReqData, 48 | pool: web::Data, 49 | query: web::Json, 50 | ) -> Result { 51 | let user_id = token.id.to_string(); 52 | pool.remove_item(&user_id, &query.problem_id) 53 | .await 54 | .map_internal_server_err()?; 55 | Ok(HttpResponse::Ok().finish()) 56 | } 57 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/endpoint/internal_api/user.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{get, post, web, HttpResponse, Responder, Result}; 2 | use serde::Deserialize; 3 | use sql_client::{internal::user_manager::UserManager, PgPool}; 4 | 5 | use crate::server::{error::ApiResult, middleware::github_auth::GithubToken}; 6 | 7 | #[get("/internal-api/user/get")] 8 | pub async fn get( 9 | token: web::ReqData, 10 | pool: web::Data, 11 | ) -> Result { 12 | let user_id = token.id.to_string(); 13 | let info = pool 14 | .get_internal_user_info(&user_id) 15 | .await 16 | .map_internal_server_err()?; 17 | Ok(HttpResponse::Ok().json(&info)) 18 | } 19 | 20 | #[derive(Deserialize)] 21 | pub struct Query { 22 | atcoder_user_id: String, 23 | } 24 | 25 | #[post("/internal-api/user/update")] 26 | pub async fn update( 27 | token: web::ReqData, 28 | pool: web::Data, 29 | body: web::Json, 30 | ) -> Result { 31 | let user_id = token.id.to_string(); 32 | pool.update_internal_user_info(&user_id, &body.atcoder_user_id) 33 | .await 34 | .map_internal_server_err()?; 35 | Ok(HttpResponse::Ok().finish()) 36 | } 37 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/endpoint/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod healthcheck; 2 | pub mod internal_api; 3 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/error.rs: -------------------------------------------------------------------------------- 1 | pub trait ApiResult { 2 | fn map_internal_server_err(self) -> actix_web::Result; 3 | } 4 | 5 | impl ApiResult for std::result::Result 6 | where 7 | E: std::fmt::Debug + std::fmt::Display + 'static, 8 | { 9 | fn map_internal_server_err(self) -> actix_web::Result { 10 | self.map_err(actix_web::error::ErrorInternalServerError) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/language_count.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{error, web, HttpRequest, HttpResponse, Result}; 2 | use sql_client::{language_count::LanguageCountClient, PgPool}; 3 | 4 | pub(crate) async fn get_language_list( 5 | _request: HttpRequest, 6 | pool: web::Data, 7 | ) -> Result { 8 | let languages = pool 9 | .load_languages() 10 | .await 11 | .map_err(error::ErrorInternalServerError)?; 12 | let response = HttpResponse::Ok().json(&languages); 13 | Ok(response) 14 | } 15 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/middleware/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod github_auth; 2 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod endpoint; 2 | pub mod error; 3 | pub(crate) mod language_count; 4 | pub mod middleware; 5 | pub(crate) mod ranking; 6 | pub(crate) mod services; 7 | pub(crate) mod time_submissions; 8 | pub(crate) mod user_info; 9 | pub(crate) mod user_submissions; 10 | 11 | use actix_web::{http::header, web, App, HttpResponseBuilder, HttpServer}; 12 | use anyhow::Result; 13 | pub use services::config_services; 14 | 15 | use self::middleware::github_auth::{GithubAuthentication, GithubClient}; 16 | 17 | const LOG_TEMPLATE: &str = r#"{"method":"%{method}xi", "url":"%U", "status":%s, "duration":%T}"#; 18 | 19 | pub async fn run_server( 20 | pg_pool: sql_client::PgPool, 21 | github_client: GithubClient, 22 | port: u16, 23 | ) -> Result<()> { 24 | let host = "0.0.0.0"; 25 | HttpServer::new(move || { 26 | App::new() 27 | .app_data(web::Data::new(github_client.clone())) 28 | .app_data(web::Data::new(pg_pool.clone())) 29 | .wrap(GithubAuthentication::new(github_client.clone())) 30 | .wrap( 31 | actix_web::middleware::Logger::new(LOG_TEMPLATE) 32 | .custom_request_replace("method", |req| req.method().to_string()), 33 | ) 34 | .configure(services::config_services) 35 | }) 36 | .bind((host, port))? 37 | .workers(4) 38 | .run() 39 | .await?; 40 | Ok(()) 41 | } 42 | 43 | pub(crate) trait MakeCors { 44 | fn make_cors(&mut self) -> &mut Self; 45 | } 46 | 47 | impl MakeCors for HttpResponseBuilder { 48 | fn make_cors(&mut self) -> &mut Self { 49 | self.insert_header((header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/ranking/ac_count.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | RankingRequest, RankingRequestFormat, RankingResponse, RankingSelector, UserRankRequest, 3 | UserRankResponse, UserRankSelector, 4 | }; 5 | 6 | use actix_web::{error, web, Result}; 7 | use async_trait::async_trait; 8 | use sql_client::{accepted_count::AcceptedCountClient, PgPool}; 9 | 10 | pub(crate) struct AcRanking; 11 | 12 | #[async_trait(?Send)] 13 | impl RankingSelector for AcRanking { 14 | type Request = RankingRequest; 15 | type Response = RankingResponse; 16 | async fn fetch(pool: web::Data, query: Self::Request) -> Result> { 17 | let ranking = pool 18 | .load_accepted_count_in_range(query.range()) 19 | .await 20 | .map_err(error::ErrorInternalServerError)?; 21 | Ok(ranking 22 | .into_iter() 23 | .map(|entry| RankingResponse { 24 | user_id: entry.user_id, 25 | count: entry.problem_count as i64, 26 | }) 27 | .collect()) 28 | } 29 | } 30 | 31 | #[async_trait(?Send)] 32 | impl UserRankSelector for AcRanking { 33 | type Request = UserRankRequest; 34 | type Response = UserRankResponse; 35 | async fn fetch( 36 | pool: web::Data, 37 | query: Self::Request, 38 | ) -> Result> { 39 | let count = match pool.get_users_accepted_count(&query.user).await { 40 | Some(number) => number, 41 | None => return Ok(None), 42 | }; 43 | let rank = pool 44 | .get_accepted_count_rank(count) 45 | .await 46 | .map_err(error::ErrorInternalServerError)?; 47 | Ok(Some(UserRankResponse { count, rank })) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/ranking/rated_point_sum.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | RankingRequest, RankingRequestFormat, RankingResponse, RankingSelector, UserRankRequest, 3 | UserRankResponse, UserRankSelector, 4 | }; 5 | 6 | use actix_web::{error, web, Result}; 7 | use async_trait::async_trait; 8 | use sql_client::{rated_point_sum::RatedPointSumClient, PgPool}; 9 | 10 | pub(crate) struct RatedPointSumRanking; 11 | 12 | #[async_trait(?Send)] 13 | impl RankingSelector for RatedPointSumRanking { 14 | type Request = RankingRequest; 15 | type Response = RankingResponse; 16 | async fn fetch(pool: web::Data, query: Self::Request) -> Result> { 17 | let ranking = pool 18 | .load_rated_point_sum_in_range(query.range()) 19 | .await 20 | .map_err(error::ErrorInternalServerError)?; 21 | Ok(ranking 22 | .into_iter() 23 | .map(|entry| RankingResponse { 24 | user_id: entry.user_id, 25 | count: entry.point_sum, 26 | }) 27 | .collect()) 28 | } 29 | } 30 | 31 | #[async_trait(?Send)] 32 | impl UserRankSelector for RatedPointSumRanking { 33 | type Request = UserRankRequest; 34 | type Response = UserRankResponse; 35 | async fn fetch( 36 | pool: web::Data, 37 | query: Self::Request, 38 | ) -> Result> { 39 | let point_sum = pool.get_users_rated_point_sum(&query.user).await; 40 | let point_sum = match point_sum { 41 | Some(point_sum) => point_sum, 42 | None => return Ok(None), 43 | }; 44 | 45 | let rank = pool 46 | .get_rated_point_sum_rank(point_sum) 47 | .await 48 | .map_err(error::ErrorInternalServerError)?; 49 | let response = UserRankResponse { 50 | count: point_sum, 51 | rank, 52 | }; 53 | Ok(Some(response)) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/ranking/streak.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | RankingRequest, RankingRequestFormat, RankingResponse, RankingSelector, UserRankRequest, 3 | UserRankResponse, UserRankSelector, 4 | }; 5 | 6 | use actix_web::{error, web, Result}; 7 | use async_trait::async_trait; 8 | use sql_client::{streak::StreakClient, PgPool}; 9 | 10 | pub(crate) struct StreakRanking; 11 | 12 | #[async_trait(?Send)] 13 | impl RankingSelector for StreakRanking { 14 | type Request = RankingRequest; 15 | type Response = RankingResponse; 16 | async fn fetch(pool: web::Data, query: Self::Request) -> Result> { 17 | let ranking = pool 18 | .load_streak_count_in_range(query.range()) 19 | .await 20 | .map_err(error::ErrorInternalServerError)?; 21 | Ok(ranking 22 | .into_iter() 23 | .map(|entry| RankingResponse { 24 | user_id: entry.user_id, 25 | count: entry.streak, 26 | }) 27 | .collect()) 28 | } 29 | } 30 | 31 | #[async_trait(?Send)] 32 | impl UserRankSelector for StreakRanking { 33 | type Request = UserRankRequest; 34 | type Response = UserRankResponse; 35 | async fn fetch( 36 | pool: web::Data, 37 | query: Self::Request, 38 | ) -> Result> { 39 | let count = match pool.get_users_streak_count(&query.user).await { 40 | Some(number) => number, 41 | None => return Ok(None), 42 | }; 43 | let rank = pool 44 | .get_streak_count_rank(count) 45 | .await 46 | .map_err(error::ErrorInternalServerError)?; 47 | Ok(Some(UserRankResponse { count, rank })) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/time_submissions.rs: -------------------------------------------------------------------------------- 1 | use crate::server::MakeCors; 2 | use actix_web::{error, web, HttpResponse, Result}; 3 | use sql_client::{ 4 | submission_client::{SubmissionClient, SubmissionRequest}, 5 | PgPool, 6 | }; 7 | 8 | pub(crate) async fn get_time_submissions( 9 | pool: web::Data, 10 | from: web::Path, 11 | ) -> Result { 12 | let from_epoch_second = from.into_inner(); 13 | let submissions: Vec<_> = pool 14 | .get_submissions(SubmissionRequest::FromTime { 15 | from_second: from_epoch_second, 16 | count: 1000, 17 | }) 18 | .await 19 | .map_err(error::ErrorInternalServerError)?; 20 | let response = HttpResponse::Ok().make_cors().json(&submissions); 21 | Ok(response) 22 | } 23 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/server/user_info.rs: -------------------------------------------------------------------------------- 1 | use crate::server::MakeCors; 2 | 3 | use actix_web::{error, web, HttpResponse, Result}; 4 | use serde::{Deserialize, Serialize}; 5 | use sql_client::accepted_count::AcceptedCountClient; 6 | use sql_client::rated_point_sum::RatedPointSumClient; 7 | use sql_client::PgPool; 8 | 9 | #[derive(Deserialize)] 10 | pub(crate) struct Query { 11 | user: String, 12 | } 13 | #[derive(Serialize)] 14 | struct UserInfo { 15 | user_id: String, 16 | accepted_count: i64, 17 | accepted_count_rank: i64, 18 | rated_point_sum: i64, 19 | rated_point_sum_rank: i64, 20 | } 21 | 22 | pub(crate) async fn get_user_info( 23 | pool: web::Data, 24 | query: web::Query, 25 | ) -> Result { 26 | let query = query.into_inner(); 27 | let user_id = query.user; 28 | 29 | let accepted_count = pool.get_users_accepted_count(&user_id).await.unwrap_or(0); 30 | let accepted_count_rank = pool 31 | .get_accepted_count_rank(accepted_count) 32 | .await 33 | .map_err(error::ErrorInternalServerError)?; 34 | let rated_point_sum = pool.get_users_rated_point_sum(&user_id).await.unwrap_or(0); 35 | let rated_point_sum_rank = pool 36 | .get_rated_point_sum_rank(rated_point_sum) 37 | .await 38 | .map_err(error::ErrorInternalServerError)?; 39 | 40 | let user_info = UserInfo { 41 | user_id, 42 | accepted_count, 43 | accepted_count_rank, 44 | rated_point_sum, 45 | rated_point_sum_rank, 46 | }; 47 | let response = HttpResponse::Ok().make_cors().json(&user_info); 48 | Ok(response) 49 | } 50 | -------------------------------------------------------------------------------- /atcoder-problems-backend/src/utils.rs: -------------------------------------------------------------------------------- 1 | use fern; 2 | use log::LevelFilter; 3 | 4 | use anyhow::Result; 5 | 6 | pub const EXCLUDED_USERS: [&str; 17] = [ 7 | "vjudge1", 8 | "vjudge2", 9 | "vjudge3", 10 | "vjudge4", 11 | "vjudge5", 12 | "luogu__bot1", 13 | "luogu__bot2", 14 | "luogu__bot4", 15 | "luogu__bot5", 16 | "luogu_bot", 17 | "luogu_bot0", 18 | "luogu_bot1", 19 | "luogu_bot2", 20 | "luogu_bot3", 21 | "luogu_bot4", 22 | "luogu_bot5", 23 | "luogu_bot6", 24 | ]; 25 | 26 | pub fn init_log_config() -> Result<()> { 27 | fern::Dispatch::new() 28 | .format(|out, message, _record| out.finish(format_args!("{}", message))) 29 | .level(LevelFilter::Info) 30 | .level_for("sqlx", LevelFilter::Warn) 31 | .level_for("actix_web", LevelFilter::Warn) 32 | .level_for("reqwest", LevelFilter::Warn) 33 | .chain(std::io::stdout()) 34 | .apply()?; 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /atcoder-problems-backend/tests/test_server_e2e_language_count.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{test, web, App}; 2 | use atcoder_problems_backend::server::config_services; 3 | use serde_json::{json, Value}; 4 | use sql_client::PgPool; 5 | 6 | pub mod utils; 7 | 8 | async fn insert_data_set1(conn: &PgPool) { 9 | sql_client::query( 10 | r"INSERT INTO language_count (user_id, simplified_language, problem_count) 11 | VALUES 12 | ('user1', 'lang1', 1), 13 | ('user1', 'lang2', 300), 14 | ('user2', 'lang1', 3), 15 | ('user3', 'lang3', 2)", 16 | ) 17 | .execute(conn) 18 | .await 19 | .unwrap(); 20 | } 21 | 22 | async fn insert_data_set2(conn: &PgPool) { 23 | sql_client::query( 24 | r"INSERT INTO language_count (user_id, simplified_language, problem_count) 25 | VALUES 26 | ('user1', 'lang4', 1), 27 | ('user4', 'lang1', 2)", 28 | ) 29 | .execute(conn) 30 | .await 31 | .unwrap(); 32 | } 33 | 34 | #[actix_web::test] 35 | async fn test_language_count() { 36 | let conn = utils::initialize_and_connect_to_test_sql().await; 37 | let app = test::init_service( 38 | App::new() 39 | .app_data(web::Data::new(conn.clone())) 40 | .configure(config_services), 41 | ) 42 | .await; 43 | 44 | insert_data_set1(&conn).await; 45 | let request = test::TestRequest::get() 46 | .uri("/atcoder-api/v3/language_list") 47 | .to_request(); 48 | let response = test::call_service(&app, request).await; 49 | assert_eq!(response.status(), actix_web::http::StatusCode::OK); 50 | let response: Value = test::read_body_json(response).await; 51 | assert_eq!(response, json!(["lang1", "lang2", "lang3"])); 52 | 53 | insert_data_set2(&conn).await; 54 | let request = test::TestRequest::get() 55 | .uri("/atcoder-api/v3/language_list") 56 | .to_request(); 57 | let response = test::call_service(&app, request).await; 58 | assert_eq!(response.status(), actix_web::http::StatusCode::OK); 59 | let response: Value = test::read_body_json(response).await; 60 | assert_eq!(response, json!(["lang1", "lang2", "lang3", "lang4"])); 61 | } 62 | -------------------------------------------------------------------------------- /atcoder-problems-backend/tests/utils.rs: -------------------------------------------------------------------------------- 1 | use atcoder_problems_backend::server::middleware::github_auth::GithubToken; 2 | use httpmock::MockServer; 3 | use serde_json::json; 4 | use sql_client::{initialize_pool, PgPool}; 5 | use std::fs::read_to_string; 6 | 7 | const SQL_FILE: &str = "../config/database-definition.sql"; 8 | const SQL_URL_ENV_KEY: &str = "SQL_URL"; 9 | 10 | pub fn get_sql_url_from_env() -> String { 11 | std::env::var(SQL_URL_ENV_KEY).unwrap() 12 | } 13 | 14 | pub async fn initialize_and_connect_to_test_sql() -> PgPool { 15 | let conn = initialize_pool(get_sql_url_from_env()).await.unwrap(); 16 | 17 | for query_str in read_to_string(SQL_FILE).unwrap().split(';') { 18 | sql_client::query(query_str).execute(&conn).await.unwrap(); 19 | } 20 | conn 21 | } 22 | 23 | pub fn start_mock_github_server(access_token: &str) -> MockServer { 24 | let server = MockServer::start(); 25 | server.mock(|when, then| { 26 | when.method("POST").path("/login/oauth/access_token"); 27 | then.status(200) 28 | .json_body(json!({ "access_token": access_token })); 29 | }); 30 | server 31 | } 32 | 33 | pub fn start_mock_github_api_server(access_token: &str, token: GithubToken) -> MockServer { 34 | let server = MockServer::start(); 35 | let token_header = format!("token {}", access_token); 36 | server.mock(|when, then| { 37 | when.method("GET") 38 | .path("/user") 39 | .header("Authorization", &token_header); 40 | then.status(200).json_body_obj(&token); 41 | }); 42 | server 43 | } 44 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{ts, tsx}] 4 | indent_size = 2 5 | indent_style = space 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | max_line_length = 80 9 | charset = utf-8 10 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_INTERNAL_API_URL=https://kenkoooo.com/atcoder/internal-api 2 | REACT_APP_ATCODER_API_URL=https://kenkoooo.com/atcoder/atcoder-api 3 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_INTERNAL_API_URL=/internal-api 2 | REACT_APP_ATCODER_API_URL=/atcoder-api 3 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 12 | "plugin:import/errors", 13 | "plugin:import/warnings", 14 | "plugin:import/typescript", 15 | "plugin:react/recommended", 16 | "plugin:react-hooks/recommended", 17 | "plugin:prettier/recommended", 18 | "prettier/@typescript-eslint", 19 | "prettier/react", 20 | ], 21 | parser: "@typescript-eslint/parser", 22 | parserOptions: { 23 | project: ["./tsconfig.json", "./cypress/tsconfig.json"], 24 | sourceType: "module", 25 | tsconfigRootDir: __dirname, 26 | }, 27 | plugins: ["@typescript-eslint"], 28 | rules: { 29 | "react/prop-types": 0, // we do not employ 'prop-types' 30 | "@typescript-eslint/camelcase": 0, // API responses contain snake_case properties 31 | 32 | // Since we can know return types of functions by type analysis of IDEs, 33 | // explicit return types are no longer required. 34 | "@typescript-eslint/explicit-function-return-type": "off", 35 | 36 | // We will never publish it. 37 | "@typescript-eslint/explicit-module-boundary-types": "off", 38 | 39 | // Returning any is not allowed in this project. 40 | // Passing any to functions is allowed, 41 | // only when the function is a type checker or it's required to use JS function. 42 | "@typescript-eslint/no-explicit-any": "error", 43 | "import/order": 1, // sort import in files 44 | "import/no-default-export": "error", 45 | "@typescript-eslint/no-non-null-assertion": "error", 46 | // TODO: enable the following rules in the future 47 | "require-atomic-updates": 0, // https://github.com/eslint/eslint/issues/11899 48 | }, 49 | settings: { 50 | react: { version: "detect" }, 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .eslintcache 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "trailingComma": "es5", 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix "" 2 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/README.md: -------------------------------------------------------------------------------- 1 | # atcoder-problems-frontend 2 | 3 | `atcoder-problems-frontend` is a web application written in TypeScript. 4 | 5 | ## Start the web application on your local machine 6 | 7 | ```bash 8 | docker-compose up -d 9 | ``` 10 | 11 | You can see it on . 12 | 13 | ## Login on the local instance 14 | 15 | To log in on your local instance for developing features related to logged in users: 16 | 17 | 1. Login at and open the developer tools (Press F12). 18 | 1. Go to "Storage" (Mozilla Firefox) / "Application" (Google Chrome) → "Cookies" and copy the cookie value with name `token`. 19 | 1. Open the local instance of AtCoder Problems in your browser (e.g. ). 20 | 1. Open the "Cookies" section again and create a cookie named `token`. Paste the cookie value. 21 | 22 | If you intend to use your own instance of backend, consult the [backend documentation](../atcoder-problems-backend/README.md). 23 | 24 | ## Create a production build 25 | 26 | ```bash 27 | docker-compose exec frontend-development yarn build 28 | ``` 29 | 30 | ## Run unit test 31 | 32 | ```bash 33 | docker-compose exec frontend-development yarn test 34 | ``` 35 | 36 | ## Run end-to-end test 37 | 38 | ```bash 39 | yarn cy:run 40 | ``` 41 | 42 | ## Open Cypress Test Runner 43 | 44 | ```bash 45 | yarn cy:open 46 | ``` 47 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000/atcoder", 3 | "defaultCommandTimeout": 10000 4 | } 5 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress/fixtures/results/empty.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress/fixtures/results/rival.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 2277427, 4 | "epoch_second": 1522333697, 5 | "problem_id": "abc076_d", 6 | "contest_id": "abc076", 7 | "user_id": "rival", 8 | "language": "Rust (1.15.1)", 9 | "point": 400.0, 10 | "length": 2770, 11 | "result": "AC", 12 | "execution_time": 2 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress/fixtures/results/user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 4272642, 4 | "epoch_second": 1550253178, 5 | "problem_id": "arc101_b", 6 | "contest_id": "abc107", 7 | "user_id": "user", 8 | "language": "Rust (1.15.1)", 9 | "point": 700.0, 10 | "length": 3532, 11 | "result": "AC", 12 | "execution_time": 981 13 | }, 14 | { 15 | "id": 430162, 16 | "epoch_second": 1434880327, 17 | "problem_id": "abc018_4", 18 | "contest_id": "abc018", 19 | "user_id": "user", 20 | "language": "Java (OpenJDK 1.7.0)", 21 | "point": 0.0, 22 | "length": 1552, 23 | "result": "WA", 24 | "execution_time": 1780 25 | }, 26 | { 27 | "id": 430153, 28 | "epoch_second": 1434878942, 29 | "problem_id": "abc018_2", 30 | "contest_id": "abc018", 31 | "user_id": "user", 32 | "language": "Java (OpenJDK 1.7.0)", 33 | "point": 100.0, 34 | "length": 699, 35 | "result": "AC", 36 | "execution_time": 400 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress/integration/common.ts: -------------------------------------------------------------------------------- 1 | export const deleteDb = (dbname: string) => { 2 | return new Promise((resolve, reject) => { 3 | const deleteDbReq = indexedDB.deleteDatabase(dbname); 4 | deleteDbReq.onsuccess = () => resolve(); 5 | deleteDbReq.onerror = reject; 6 | deleteDbReq.onblocked = reject; 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | }; 22 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["../node_modules/cypress", "./*/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenkoooo/AtCoderProblems/ff203e2dc12fece1e5c85dfcbd92db7312313634/atcoder-problems-frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /atcoder-problems-frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenkoooo/AtCoderProblems/ff203e2dc12fece1e5c85dfcbd92db7312313634/atcoder-problems-frontend/public/favicon.ico -------------------------------------------------------------------------------- /atcoder-problems-frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 16 | 20 | 21 | 25 | 26 | 35 | AtCoder Problems 36 | 37 | 38 | 39 | 40 |
41 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "AtCoder Problems", 3 | "name": "AtCoder Problems", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/public/static_data/backend/hidden_contests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "ukuku09", 4 | "start_epoch_second": 1530939600, 5 | "duration_second": 10800, 6 | "title": "ウクーニャたんお誕生日コンテスト", 7 | "rate_change": "-" 8 | }, 9 | { 10 | "id": "summerfes2018-div1", 11 | "start_epoch_second": 1535175000, 12 | "duration_second": 7200, 13 | "title": "Summer Festival Contest 2018 (Division 1)", 14 | "rate_change": "-" 15 | }, 16 | { 17 | "id": "summerfes2018-div2", 18 | "start_epoch_second": 1535175000, 19 | "duration_second": 7200, 20 | "title": "Summer Festival Contest 2018 (Division 2)", 21 | "rate_change": "-" 22 | }, 23 | { 24 | "id": "monamieHB2021", 25 | "start_epoch_second": 1618920000, 26 | "duration_second": 7200, 27 | "title": "えびまのお誕生日コンテスト 2021 Day 1", 28 | "rate_change": "-" 29 | }, 30 | { 31 | "id": "tkppc6-1", 32 | "start_epoch_second": 1629604800, 33 | "duration_second": 10800, 34 | "title": "技術室奥プログラミングコンテスト#6 Day1", 35 | "rate_change": "-" 36 | }, 37 | { 38 | "id": "genocon2021", 39 | "start_epoch_second": 1629720000, 40 | "duration_second": 2429940, 41 | "title": "ゲノコン2021 ー DNA配列解析チャレンジ", 42 | "rate_change": "-" 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": "serve", 3 | "headers": [ 4 | { 5 | "source": "**/*", 6 | "headers": [ 7 | { 8 | "key": "Cache-Control", 9 | "value": "no-cache" 10 | } 11 | ] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/api/InternalAPIClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProblemList, 3 | ProgressResetList, 4 | UserResponse, 5 | VirtualContestDetails, 6 | VirtualContestInfo, 7 | } from "../pages/Internal/types"; 8 | import { useSWRData } from "./index"; 9 | 10 | const BASE_URL = process.env.REACT_APP_INTERNAL_API_URL; 11 | export const USER_GET = `${BASE_URL}/user/get`; 12 | 13 | const typeCastFetcher = (url: string) => 14 | fetch(url) 15 | .then((response) => response.json()) 16 | .then((response) => response as T); 17 | 18 | export const useLoginState = () => { 19 | return useSWRData(USER_GET, (url) => typeCastFetcher(url)); 20 | }; 21 | 22 | export const useVirtualContest = (contestId: string) => { 23 | const url = `${BASE_URL}/contest/get/${contestId}`; 24 | return useSWRData(url, (url) => typeCastFetcher(url)); 25 | }; 26 | 27 | export const useMyContests = () => { 28 | return useSWRData(`${BASE_URL}/contest/my`, (url) => 29 | typeCastFetcher(url) 30 | ); 31 | }; 32 | export const useJoinedContests = () => { 33 | return useSWRData(`${BASE_URL}/contest/joined`, (url) => 34 | typeCastFetcher(url) 35 | ); 36 | }; 37 | 38 | export const useProblemList = (listId: string) => { 39 | return useSWRData(`${BASE_URL}/list/get/${listId}`, (url) => 40 | typeCastFetcher(url) 41 | ); 42 | }; 43 | 44 | export const useProgressResetList = () => { 45 | return useSWRData(`${BASE_URL}/progress_reset/list`, (url) => 46 | typeCastFetcher(url) 47 | ); 48 | }; 49 | 50 | export const useRecentContests = () => { 51 | return useSWRData(`${BASE_URL}/contest/recent`, (url) => 52 | typeCastFetcher(url) 53 | ); 54 | }; 55 | 56 | export const useMyList = () => { 57 | return useSWRData(`${BASE_URL}/list/my`, (url) => 58 | typeCastFetcher(url) 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import useSWR, { SWRConfiguration } from "swr"; 2 | 3 | export const useSWRData = ( 4 | url: string, 5 | fetcher: (url: string) => Promise, 6 | config: SWRConfiguration = {} 7 | ) => { 8 | return useSWR(url, fetcher, { 9 | revalidateOnFocus: false, 10 | revalidateOnReconnect: false, 11 | refreshWhenHidden: true, 12 | ...config, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/ContestLink.test.tsx: -------------------------------------------------------------------------------- 1 | import { AGC_001_START } from "../utils/ContestClassifier"; 2 | import { getRatedTarget, RatedTargetType } from "./ContestLink"; 3 | 4 | const DEFAULT_CONTEST = { 5 | id: "", 6 | contest_id: "", 7 | title: "", 8 | start_epoch_second: AGC_001_START, 9 | duration_second: 0, 10 | rate_change: "-", 11 | }; 12 | 13 | describe("Infer rating change of contests", () => { 14 | it("ARC level", () => { 15 | const contest = { 16 | ...DEFAULT_CONTEST, 17 | rate_change: " ~ 2799", 18 | }; 19 | 20 | expect(getRatedTarget(contest)).toBe(2799); 21 | }); 22 | it("new ABC level", () => { 23 | const contest = { 24 | ...DEFAULT_CONTEST, 25 | rate_change: " ~ 1999", 26 | }; 27 | 28 | expect(getRatedTarget(contest)).toBe(1999); 29 | }); 30 | it("old ABC level", () => { 31 | const contest = { 32 | ...DEFAULT_CONTEST, 33 | rate_change: " ~ 1119", 34 | }; 35 | 36 | expect(getRatedTarget(contest)).toBe(1119); 37 | }); 38 | it("old AGC level", () => { 39 | const contest = { 40 | ...DEFAULT_CONTEST, 41 | rate_change: "All", 42 | }; 43 | 44 | expect(getRatedTarget(contest)).toBe(RatedTargetType.All); 45 | }); 46 | it("new AGC level", () => { 47 | const contest = { 48 | ...DEFAULT_CONTEST, 49 | rate_change: "1200 ~", 50 | }; 51 | 52 | expect(getRatedTarget(contest)).toBe(RatedTargetType.All); 53 | }); 54 | it("buggy unrated", () => { 55 | const contest = { 56 | ...DEFAULT_CONTEST, 57 | start_epoch_second: 0, 58 | rate_change: "All", 59 | }; 60 | 61 | expect(getRatedTarget(contest)).toBe(RatedTargetType.Unrated); 62 | }); 63 | it("unrated", () => { 64 | const contest = { 65 | ...DEFAULT_CONTEST, 66 | rate_change: "-", 67 | }; 68 | 69 | expect(getRatedTarget(contest)).toBe(RatedTargetType.Unrated); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/ContestLink.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as Url from "../utils/Url"; 3 | import Contest from "../interfaces/Contest"; 4 | import { getRatingColorClass } from "../utils"; 5 | import { AGC_001_START } from "../utils/ContestClassifier"; 6 | import { NewTabLink } from "./NewTabLink"; 7 | 8 | interface Props { 9 | contest: Contest; 10 | title?: string; 11 | } 12 | 13 | export enum RatedTargetType { 14 | All, 15 | Unrated, 16 | } 17 | 18 | type RatedTarget = number | RatedTargetType; 19 | 20 | export function getRatedTarget(contest: Contest): RatedTarget { 21 | if (AGC_001_START > contest.start_epoch_second) { 22 | return RatedTargetType.Unrated; 23 | } 24 | switch (contest.rate_change) { 25 | case undefined: 26 | return RatedTargetType.Unrated; 27 | case "-": 28 | return RatedTargetType.Unrated; 29 | case "All": 30 | return RatedTargetType.All; 31 | default: { 32 | const range = contest.rate_change.split("~").map((r) => r.trim()); 33 | if (range.length !== 2) { 34 | return RatedTargetType.Unrated; 35 | } 36 | const upperBound = parseInt(range[1]); 37 | if (upperBound) { 38 | return upperBound; 39 | } 40 | const lowerBound = parseInt(range[0]); 41 | if (lowerBound) { 42 | return RatedTargetType.All; 43 | } 44 | return RatedTargetType.Unrated; 45 | } 46 | } 47 | } 48 | 49 | function getColorClass(target: RatedTarget): string { 50 | if (target === RatedTargetType.All) { 51 | return "difficulty-red"; 52 | } 53 | if (target === RatedTargetType.Unrated) { 54 | return ""; 55 | } 56 | return getRatingColorClass(target); 57 | } 58 | 59 | export const ContestLink: React.FC = (props) => { 60 | const { contest, title } = props; 61 | const target: RatedTarget = getRatedTarget(contest); 62 | 63 | return ( 64 | <> 65 | {" "} 66 | 67 | {title !== undefined ? title : contest.title} 68 | 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/GoogleCalendarButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "reactstrap"; 3 | 4 | interface Props { 5 | contestId: string; 6 | title: string; 7 | startEpochSecond: number; 8 | endEpochSecond: number; 9 | } 10 | 11 | /** 12 | * Google Calendar accepts a subset of ISO 8601 combined date and time. 13 | * Ref. https://stackoverflow.com/a/41733538 14 | * Ref. (archived document) https://web.archive.org/web/20120313011336/http://www.google.com/googlecalendar/event_publisher_guide.html 15 | * @param epochSeconds Date to format in epoch seconds. 16 | * @return Date formatted to generate Google Calendar URL. 17 | */ 18 | const formatDateForGoogleCalendar = (epochSeconds: number): string => { 19 | const isoStringWithoutMillisecond = 20 | new Date(epochSeconds * 1000).toISOString().split(".")[0] + "Z"; 21 | return isoStringWithoutMillisecond.replace(/[^0-9TZ]/g, ""); 22 | }; 23 | 24 | export const GoogleCalendarButton: React.FC = (props) => { 25 | const internalUrl = `https://kenkoooo.com/atcoder/#/contest/show/${props.contestId}`; 26 | const startDate = formatDateForGoogleCalendar(props.startEpochSecond); 27 | const endDate = formatDateForGoogleCalendar(props.endEpochSecond); 28 | const shareUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent( 29 | props.title 30 | )}&dates=${startDate}/${endDate}&location=${encodeURIComponent(internalUrl)}`; 31 | return ( 32 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/HelpBadgeModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Modal, ModalHeader, ModalBody, Badge, Tooltip } from "reactstrap"; 3 | 4 | interface Props { 5 | id: string; 6 | children: React.ReactNode; 7 | title?: React.ReactNode; 8 | tooltipText?: React.ReactNode; 9 | } 10 | 11 | export const HelpBadgeModal = (props: Props): JSX.Element => { 12 | const { title, children, id, tooltipText } = props; 13 | const [isModalOpen, setModalOpen] = useState(false); 14 | const [isTooltipOpen, setTooltipOpen] = useState(false); 15 | 16 | const toggleModal = (): void => setModalOpen(!isModalOpen); 17 | const badgeId = "HelpBadgeTooltipModal-" + id; 18 | 19 | return ( 20 | <> 21 | 28 | ? 29 | 30 | setTooltipOpen(!isTooltipOpen)} 35 | > 36 | {tooltipText ?? "Click to see detailed explanation."} 37 | 38 | 39 | {title ?? "Help"} 40 | {children} 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/HelpBadgeTooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Badge, Tooltip } from "reactstrap"; 3 | 4 | interface Props { 5 | id: string; 6 | } 7 | 8 | interface LocalState { 9 | tooltipOpen: boolean; 10 | } 11 | 12 | export class HelpBadgeTooltip extends React.Component { 13 | constructor(props: Props) { 14 | super(props); 15 | this.state = { 16 | tooltipOpen: false, 17 | }; 18 | } 19 | 20 | render(): React.ReactNode { 21 | const { tooltipOpen } = this.state; 22 | const badgeId = "HelpBadgeTooltip-" + this.props.id; 23 | return ( 24 | <> 25 | 26 | ? 27 | 28 | this.setState({ tooltipOpen: !tooltipOpen })} 33 | > 34 | {this.props.children} 35 | 36 | 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/NewTabLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | 3 | interface Props extends React.HTMLAttributes { 4 | href?: string; 5 | children: ReactNode; 6 | className?: string; 7 | } 8 | 9 | export const NewTabLink: React.FC = (props) => ( 10 | // Don't add rel="noreferrer" to AtCoder links 11 | // to allow AtCoder get the referral information. 12 | // eslint-disable-next-line react/jsx-no-target-blank 13 | 22 | {props.children} 23 | 24 | ); 25 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/Problempoint.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | point: number; 5 | } 6 | 7 | const formatPoint = (point: number): string => { 8 | const INF_POINT = 1e18; 9 | if (point >= INF_POINT) { 10 | return ""; 11 | } else { 12 | return point.toString(); 13 | } 14 | }; 15 | 16 | export const ProblemPoint: React.FC = (props) => { 17 | const { point } = props; 18 | return
{formatPoint(point)}
; 19 | }; 20 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/SubmitTimespan.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Contest from "../interfaces/Contest"; 3 | import { ProblemStatus, StatusLabel } from "../interfaces/Status"; 4 | 5 | interface Props { 6 | contest: Contest; 7 | problemStatus?: ProblemStatus; 8 | showPenalties: boolean; 9 | } 10 | 11 | const formatTimespan = (sec: number): string => { 12 | let sign; 13 | if (sec >= 0) { 14 | sign = ""; 15 | } else { 16 | sign = "-"; 17 | sec *= -1; 18 | } 19 | return `${sign}${Math.floor(sec / 60)}:${`0${sec % 60}`.slice(-2)}`; 20 | }; 21 | 22 | export const SubmitTimespan: React.FC = (props) => { 23 | const { contest, problemStatus, showPenalties } = props; 24 | if (!problemStatus || problemStatus.label === StatusLabel.None) { 25 | return null; 26 | } 27 | 28 | const penalty = problemStatus.rejectedEpochSeconds.filter( 29 | (epoch) => epoch <= contest.start_epoch_second + contest.duration_second 30 | ).length; 31 | 32 | return ( 33 |
34 | {problemStatus.label !== StatusLabel.Success || 35 | problemStatus.epoch > contest.start_epoch_second + contest.duration_second 36 | ? "" 37 | : formatTimespan(problemStatus.epoch - contest.start_epoch_second)} 38 | {showPenalties && penalty > 0 && ( 39 | {`(${penalty})`} 40 | )} 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/ThemeSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { GoCheck } from "react-icons/go"; 3 | import { 4 | UncontrolledDropdown, 5 | DropdownToggle, 6 | DropdownMenu, 7 | DropdownItem, 8 | } from "reactstrap"; 9 | import { ThemeContext } from "./ThemeProvider"; 10 | 11 | const CheckMark = (props: { isVisible: boolean }) => { 12 | return ( 13 | 19 | ); 20 | }; 21 | 22 | export const ThemeSelector: React.FC = () => { 23 | const [themeId, setThemeId] = useContext(ThemeContext); 24 | 25 | return ( 26 | 27 | 28 | Theme 29 | 30 | 31 | setThemeId("light")} 35 | > 36 | 37 | Light 38 | 39 | setThemeId("dark")} 43 | > 44 | 45 | Dark (Beta) 46 | 47 | setThemeId("purple")} 51 | > 52 | 53 | Purple (Beta) 54 | 55 | setThemeId("auto")} 59 | > 60 | 61 | Auto 62 | 63 | 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/Timer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { formatDuration, getCurrentUnixtimeInSecond } from "../utils/DateUtil"; 3 | 4 | interface Props { 5 | end: number; 6 | } 7 | 8 | export const Timer: React.FC = (props) => { 9 | const [timeLeft, setTimeLeft] = useState( 10 | props.end - getCurrentUnixtimeInSecond() 11 | ); 12 | useEffect(() => { 13 | if (timeLeft <= 0) { 14 | return; 15 | } 16 | const intervalId = setInterval(() => { 17 | setTimeLeft(props.end - getCurrentUnixtimeInSecond()); 18 | }, 1000); 19 | return (): void => clearInterval(intervalId); 20 | }, [timeLeft, props.end]); 21 | if (timeLeft <= 0) { 22 | return null; 23 | } 24 | return {formatDuration(timeLeft)}; 25 | }; 26 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/TopcoderLikeCircle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Theme } from "../style/theme"; 3 | import { getRatingColorCode, RatingColor } from "../utils"; 4 | import { useTheme } from "./ThemeProvider"; 5 | 6 | type RatingMetalColor = "Bronze" | "Silver" | "Gold"; 7 | const getRatingMetalColorCode = (metalColor: RatingMetalColor) => { 8 | switch (metalColor) { 9 | case "Bronze": 10 | return { base: "#965C2C", highlight: "#FFDABD" }; 11 | case "Silver": 12 | return { base: "#808080", highlight: "white" }; 13 | case "Gold": 14 | return { base: "#FFD700", highlight: "white" }; 15 | } 16 | }; 17 | 18 | type RatingColorWithMetal = RatingColor | RatingMetalColor; 19 | const getStyleOptions = ( 20 | color: RatingColorWithMetal, 21 | fillRatio: number, 22 | theme: Theme 23 | ) => { 24 | if (color === "Bronze" || color === "Silver" || color === "Gold") { 25 | const metalColor = getRatingMetalColorCode(color); 26 | return { 27 | borderColor: metalColor.base, 28 | background: `linear-gradient(to right, \ 29 | ${metalColor.base}, ${metalColor.highlight}, ${metalColor.base})`, 30 | }; 31 | } else { 32 | const colorCode = getRatingColorCode(color, theme); 33 | return { 34 | borderColor: colorCode, 35 | background: `border-box linear-gradient(to top, \ 36 | ${colorCode} ${fillRatio * 100}%, \ 37 | rgba(0,0,0,0) ${fillRatio * 100}%)`, 38 | }; 39 | } 40 | }; 41 | 42 | interface Props extends React.HTMLAttributes { 43 | color: RatingColorWithMetal; 44 | rating: number; 45 | big?: boolean; 46 | } 47 | 48 | export const TopcoderLikeCircle: React.FC = (props) => { 49 | const { color, rating } = props; 50 | const fillRatio = rating >= 3200 ? 1.0 : (rating % 400) / 400; 51 | const className = `topcoder-like-circle \ 52 | ${props.big ? "topcoder-like-circle-big" : ""}`; 53 | const theme = useTheme(); 54 | const styleOptions = getStyleOptions(color, fillRatio, theme); 55 | return ( 56 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/TweetButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "reactstrap"; 3 | 4 | interface Props { 5 | id: string; 6 | text: string; 7 | color: string; 8 | children: string; 9 | } 10 | 11 | export const TweetButton: React.FC = (props) => { 12 | const internalUrl = `https://kenkoooo.com/atcoder/#/contest/show/${props.id}`; 13 | const shareUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent( 14 | internalUrl 15 | )}&text=${encodeURIComponent(props.text)}&hashtags=AtCoderProblems`; 16 | return ( 17 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/components/UserNameLabel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Tooltip } from "reactstrap"; 3 | import { useRatingInfo } from "../api/APIClient"; 4 | import { getRatingColor, getRatingColorClass } from "../utils"; 5 | import * as Url from "../utils/Url"; 6 | import { TopcoderLikeCircle } from "./TopcoderLikeCircle"; 7 | import { NewTabLink } from "./NewTabLink"; 8 | 9 | interface Props { 10 | userId: string; 11 | big?: boolean; 12 | showRating?: boolean; 13 | } 14 | 15 | const ColoredUserNameLabel = (props: Props) => { 16 | const { userId } = props; 17 | const userRatingInfo = useRatingInfo(userId); 18 | const userRating = userRatingInfo.rating; 19 | const color = 20 | userRating < 3200 21 | ? getRatingColor(userRating) 22 | : userRating < 3600 23 | ? "Silver" 24 | : "Gold"; 25 | const id = "RatingCircle-" + userId; 26 | const [tooltipOpen, setTooltipOpen] = useState(false); 27 | 28 | return ( 29 | 30 | 37 | setTooltipOpen(!tooltipOpen)} 42 | > 43 | {`Rating: ${userRating}`} 44 | 45 |   46 | 50 | {userId} 51 | 52 | 53 | ); 54 | }; 55 | 56 | export const UserNameLabel: React.FC = (props) => { 57 | const label = props.showRating ? ( 58 | 59 | ) : ( 60 | 61 | {props.userId} 62 | 63 | ); 64 | return props.big ?

{label}

: label; 65 | }; 66 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/database/index.ts: -------------------------------------------------------------------------------- 1 | const VERSION = 3; 2 | export const openDatabase = ( 3 | dbName: string, 4 | onUpgradeNeeded: (db: IDBDatabase) => Promise 5 | ) => { 6 | return new Promise( 7 | (resolve: (db: IDBDatabase) => void, reject: (msg: string) => void) => { 8 | const openDBRequest = window.indexedDB.open(dbName, VERSION); 9 | openDBRequest.onsuccess = (e) => { 10 | const db = (e.target as IDBOpenDBRequest).result; 11 | resolve(db); 12 | }; 13 | openDBRequest.onerror = () => { 14 | const msg = `Failed to open DB: ${dbName}`; 15 | console.error(msg); 16 | reject(msg); 17 | }; 18 | openDBRequest.onupgradeneeded = async (e) => { 19 | const db = (e.target as IDBOpenDBRequest).result; 20 | await onUpgradeNeeded(db); 21 | }; 22 | } 23 | ); 24 | }; 25 | export const loadAllData = (db: IDBDatabase, objectStoreName: string) => { 26 | return new Promise( 27 | (resolve: (result: T) => void, reject: (msg: string) => void) => { 28 | const request = db 29 | .transaction([objectStoreName]) 30 | .objectStore(objectStoreName) 31 | .getAll(); 32 | request.onsuccess = (e) => { 33 | const allSubmissions = (e.target as IDBRequest).result; 34 | resolve(allSubmissions); 35 | }; 36 | request.onerror = () => { 37 | const errorMessage = `Failed to load all data from ${objectStoreName}`; 38 | console.error(errorMessage); 39 | reject(errorMessage); 40 | }; 41 | } 42 | ); 43 | }; 44 | export const saveData = ( 45 | db: IDBDatabase, 46 | objectStoreName: string, 47 | data: T 48 | ) => { 49 | return new Promise((resolve, reject) => { 50 | const request = db 51 | .transaction([objectStoreName], "readwrite") 52 | .objectStore(objectStoreName) 53 | .put(data); 54 | request.onsuccess = () => { 55 | resolve(); 56 | }; 57 | request.onerror = () => { 58 | console.error( 59 | `Failed saving ${JSON.stringify(data)} to ${objectStoreName}` 60 | ); 61 | reject(); 62 | }; 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./style/index.scss"; 4 | import App from "./App"; 5 | 6 | ReactDOM.render(, document.getElementById("root")); 7 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/interfaces/Contest.ts: -------------------------------------------------------------------------------- 1 | import { hasPropertyAsType, isNumber, isString } from "../utils/TypeUtils"; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default interface Contest { 5 | readonly start_epoch_second: number; 6 | readonly rate_change: string; 7 | readonly id: string; 8 | readonly duration_second: number; 9 | readonly title: string; 10 | } 11 | 12 | export const isContest = (contest: unknown): contest is Contest => 13 | typeof contest === "object" && 14 | contest !== null && 15 | hasPropertyAsType(contest, "start_epoch_second", isNumber) && 16 | hasPropertyAsType(contest, "rate_change", isString) && 17 | hasPropertyAsType(contest, "id", isString) && 18 | hasPropertyAsType(contest, "duration_second", isNumber) && 19 | hasPropertyAsType(contest, "title", isString); 20 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/interfaces/ContestParticipation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hasPropertyAsType, 3 | isBoolean, 4 | isNumber, 5 | isString, 6 | } from "../utils/TypeUtils"; 7 | 8 | // Type interface for the Official API response from https://atcoder.jp/users//history/json. 9 | // Response type of the API is List 10 | 11 | // eslint-disable-next-line import/no-default-export 12 | export default interface ContestParticipation { 13 | readonly IsRated: boolean; 14 | readonly Place: number; 15 | readonly OldRating: number; 16 | readonly NewRating: number; 17 | readonly Performance: number; 18 | readonly InnerPerformance: number; 19 | readonly ContestScreenName: string; 20 | readonly ContestName: string; 21 | readonly EndTime: string; 22 | } 23 | 24 | export const isContestParticipation = ( 25 | obj: unknown 26 | ): obj is ContestParticipation => 27 | typeof obj === "object" && 28 | obj !== null && 29 | hasPropertyAsType(obj, "IsRated", isBoolean) && 30 | hasPropertyAsType(obj, "Place", isNumber) && 31 | hasPropertyAsType(obj, "OldRating", isNumber) && 32 | hasPropertyAsType(obj, "NewRating", isNumber) && 33 | hasPropertyAsType(obj, "Performance", isNumber) && 34 | hasPropertyAsType(obj, "InnerPerformance", isNumber) && 35 | hasPropertyAsType(obj, "ContestScreenName", isString) && 36 | hasPropertyAsType(obj, "ContestName", isString) && 37 | hasPropertyAsType(obj, "EndTime", isString); 38 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/interfaces/Course.ts: -------------------------------------------------------------------------------- 1 | export interface Course { 2 | readonly title: string; 3 | readonly set_list: { 4 | readonly order: number; 5 | readonly title: string; 6 | readonly problems: { 7 | readonly order: number; 8 | readonly problem_id: string; 9 | }[]; 10 | }[]; 11 | } 12 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/interfaces/Problem.ts: -------------------------------------------------------------------------------- 1 | import { hasPropertyAsType, isString } from "../utils/TypeUtils"; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default interface Problem { 5 | readonly id: string; 6 | readonly contest_id: string; 7 | readonly problem_index: string; 8 | readonly name: string; 9 | } 10 | 11 | export const isProblem = (obj: unknown): obj is Problem => 12 | hasPropertyAsType(obj, "id", isString) && 13 | hasPropertyAsType(obj, "contest_id", isString) && 14 | hasPropertyAsType(obj, "problem_index", isString) && 15 | hasPropertyAsType(obj, "name", isString); 16 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/interfaces/RankingEntry.ts: -------------------------------------------------------------------------------- 1 | import { hasPropertyAsType, isNumber, isString } from "../utils/TypeUtils"; 2 | 3 | export interface RankingEntry { 4 | readonly count: number; 5 | readonly user_id: string; 6 | } 7 | 8 | export const isRankingEntry = (obj: unknown): obj is RankingEntry => 9 | hasPropertyAsType(obj, "count", isNumber) && 10 | hasPropertyAsType(obj, "user_id", isString); 11 | 12 | export interface LangRankingEntry { 13 | user_id: string; 14 | count: number; 15 | language: string; 16 | } 17 | 18 | export const isLangRankingEntry = (obj: unknown): obj is LangRankingEntry => 19 | hasPropertyAsType(obj, "user_id", isString) && 20 | hasPropertyAsType(obj, "count", isNumber) && 21 | hasPropertyAsType(obj, "language", isString); 22 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/interfaces/Submission.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hasPropertyAsType, 3 | hasPropertyAsTypeOrNull, 4 | isNumber, 5 | isString, 6 | } from "../utils/TypeUtils"; 7 | 8 | // eslint-disable-next-line import/no-default-export 9 | export default interface Submission { 10 | readonly execution_time: number | null; 11 | readonly point: number; 12 | readonly result: string; 13 | readonly problem_id: string; 14 | readonly user_id: string; 15 | readonly epoch_second: number; 16 | readonly contest_id: string; 17 | readonly id: number; 18 | readonly language: string; 19 | readonly length: number; 20 | } 21 | 22 | export const isSubmission = (obj: unknown): obj is Submission => 23 | hasPropertyAsTypeOrNull(obj, "execution_time", isNumber) && 24 | hasPropertyAsType(obj, "point", isNumber) && 25 | hasPropertyAsType(obj, "result", isString) && 26 | hasPropertyAsType(obj, "problem_id", isString) && 27 | hasPropertyAsType(obj, "user_id", isString) && 28 | hasPropertyAsType(obj, "epoch_second", isNumber) && 29 | hasPropertyAsType(obj, "contest_id", isString) && 30 | hasPropertyAsType(obj, "id", isNumber) && 31 | hasPropertyAsType(obj, "language", isString) && 32 | hasPropertyAsType(obj, "length", isNumber); 33 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/interfaces/UserRankEntry.ts: -------------------------------------------------------------------------------- 1 | import { hasPropertyAsType, isNumber } from "../utils/TypeUtils"; 2 | 3 | export interface UserRankEntry { 4 | readonly count: number; 5 | readonly rank: number; 6 | } 7 | 8 | export const isUserRankEntry = (obj: unknown): obj is UserRankEntry => 9 | hasPropertyAsType(obj, "count", isNumber) && 10 | hasPropertyAsType(obj, "rank", isNumber); 11 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/interfaces/index.test.ts: -------------------------------------------------------------------------------- 1 | import { isContest } from "./Contest"; 2 | 3 | describe("Contest type guard", () => { 4 | it("should return true", () => { 5 | expect( 6 | isContest({ 7 | start_epoch_second: 10, 8 | rate_change: "", 9 | id: "", 10 | duration_second: 1, 11 | title: "", 12 | }) 13 | ).toBeTruthy(); 14 | }); 15 | it("should return false for undefined fields", () => { 16 | expect( 17 | isContest({ 18 | rate_change: "", 19 | id: "", 20 | duration_second: 1, 21 | title: "", 22 | }) 23 | ).toBeFalsy(); 24 | expect( 25 | isContest({ 26 | start_epoch_second: 10, 27 | id: "", 28 | duration_second: 1, 29 | title: "", 30 | }) 31 | ).toBeFalsy(); 32 | }); 33 | it("should return false for different types", () => { 34 | expect( 35 | isContest({ 36 | start_epoch_second: 10, 37 | rate_change: "", 38 | id: "", 39 | duration_second: 1, 40 | title: 0, 41 | }) 42 | ).toBeFalsy(); 43 | expect( 44 | isContest({ 45 | start_epoch_second: 10, 46 | rate_change: "", 47 | id: 1, 48 | duration_second: 1, 49 | title: "", 50 | }) 51 | ).toBeFalsy(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/ACRanking.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { DEFAULT_RANKING_RANGE, RemoteRanking } from "../components/Ranking"; 3 | import { useACRanking, useUserACRank } from "../api/APIClient"; 4 | 5 | export const ACRanking = () => { 6 | const [range, setRange] = useState(DEFAULT_RANKING_RANGE); 7 | const ranking = useACRanking(range.from, range.to).data ?? []; 8 | const firstUser = ranking.length === 0 ? "" : ranking[0].user_id; 9 | const firstRankOnPage = useUserACRank(firstUser).data?.rank ?? 0; 10 | 11 | return ( 12 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/FastestRanking.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useFastRanking } from "../api/APIClient"; 3 | import { Ranking } from "../components/Ranking"; 4 | 5 | export const FastestRanking = () => { 6 | const ranking = useFastRanking(); 7 | return ( 8 | 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/FirstRanking.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useFirstRanking } from "../api/APIClient"; 3 | import { Ranking } from "../components/Ranking"; 4 | 5 | export const FirstRanking = () => { 6 | const ranking = useFirstRanking(); 7 | return ; 8 | }; 9 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/Internal/ApiUrl.ts: -------------------------------------------------------------------------------- 1 | const BASE_URL = process.env.REACT_APP_INTERNAL_API_URL; 2 | 3 | export const USER_UPDATE = `${BASE_URL}/user/update`; 4 | 5 | export const CONTEST_UPDATE = `${BASE_URL}/contest/update`; 6 | export const CONTEST_JOIN = `${BASE_URL}/contest/join`; 7 | export const CONTEST_LEAVE = `${BASE_URL}/contest/leave`; 8 | export const CONTEST_CREATE = `${BASE_URL}/contest/create`; 9 | export const CONTEST_ITEM_UPDATE = `${BASE_URL}/contest/item/update`; 10 | 11 | export const LIST_MY = `${BASE_URL}/list/my`; 12 | export const LIST_CREATE = `${BASE_URL}/list/create`; 13 | export const LIST_DELETE = `${BASE_URL}/list/delete`; 14 | export const LIST_UPDATE = `${BASE_URL}/list/update`; 15 | export const LIST_ITEM_UPDATE = `${BASE_URL}/list/item/update`; 16 | export const LIST_ITEM_DELETE = `${BASE_URL}/list/item/delete`; 17 | export const LIST_ITEM_ADD = `${BASE_URL}/list/item/add`; 18 | 19 | export const PROGRESS_RESET_LIST = `${BASE_URL}/progress_reset/list`; 20 | export const PROGRESS_RESET_ADD = `${BASE_URL}/progress_reset/add`; 21 | export const PROGRESS_RESET_DELETE = `${BASE_URL}/progress_reset/delete`; 22 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/Internal/MyAccountPage/ApiClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PROGRESS_RESET_ADD, 3 | PROGRESS_RESET_DELETE, 4 | USER_UPDATE, 5 | } from "../ApiUrl"; 6 | import { getCurrentUnixtimeInSecond } from "../../../utils/DateUtil"; 7 | 8 | export const addResetProgress = (problemId: string) => 9 | fetch(PROGRESS_RESET_ADD, { 10 | method: "POST", 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | body: JSON.stringify({ 15 | problem_id: problemId, 16 | reset_epoch_second: getCurrentUnixtimeInSecond(), 17 | }), 18 | }); 19 | 20 | export const deleteResetProgress = (problemId: string) => 21 | fetch(PROGRESS_RESET_DELETE, { 22 | method: "POST", 23 | headers: { 24 | "Content-Type": "application/json", 25 | }, 26 | body: JSON.stringify({ 27 | problem_id: problemId, 28 | }), 29 | }); 30 | 31 | export const updateUserInfo = (userId: string) => 32 | fetch(USER_UPDATE, { 33 | method: "POST", 34 | headers: { 35 | "Content-Type": "application/json", 36 | }, 37 | body: JSON.stringify({ 38 | atcoder_user_id: userId, 39 | }), 40 | }); 41 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/Internal/MyAccountPage/UserIdUpdate.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Alert, Button, Col, Input, Label, Row } from "reactstrap"; 3 | 4 | interface Props { 5 | userId: string; 6 | setUserId: (userId: string) => void; 7 | status: "updating" | "updated" | "open"; 8 | onSubmit: () => void; 9 | } 10 | 11 | export const UserIdUpdate = (props: Props) => { 12 | return ( 13 |
{ 15 | event.preventDefault(); 16 | props.onSubmit(); 17 | }} 18 | > 19 | 20 | 21 |

Account Info

22 | 23 |
24 | 25 | 26 | 27 | props.setUserId(event.target.value)} 33 | /> 34 | 35 | 36 | 37 | 38 | 45 | 46 | 47 | 48 | 49 | 50 | Updated 51 | 52 | 53 | 54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/Internal/ProblemList/ApiClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LIST_ITEM_ADD, 3 | LIST_ITEM_DELETE, 4 | LIST_ITEM_UPDATE, 5 | LIST_UPDATE, 6 | } from "../ApiUrl"; 7 | 8 | export const updateProblemList = (name: string, listId: string) => 9 | fetch(LIST_UPDATE, { 10 | method: "POST", 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | body: JSON.stringify({ internal_list_id: listId, name }), 15 | }); 16 | 17 | export const addProblemItem = (problemId: string, listId: string) => 18 | fetch(LIST_ITEM_ADD, { 19 | method: "POST", 20 | headers: { 21 | "Content-Type": "application/json", 22 | }, 23 | body: JSON.stringify({ 24 | internal_list_id: listId, 25 | problem_id: problemId, 26 | }), 27 | }); 28 | 29 | export const deleteProblemItem = (problemId: string, listId: string) => 30 | fetch(LIST_ITEM_DELETE, { 31 | method: "POST", 32 | headers: { 33 | "Content-Type": "application/json", 34 | }, 35 | body: JSON.stringify({ 36 | internal_list_id: listId, 37 | problem_id: problemId, 38 | }), 39 | }); 40 | 41 | export const updateProblemItem = ( 42 | problemId: string, 43 | memo: string, 44 | listId: string 45 | ) => 46 | fetch(LIST_ITEM_UPDATE, { 47 | method: "POST", 48 | headers: { 49 | "Content-Type": "application/json", 50 | }, 51 | body: JSON.stringify({ 52 | internal_list_id: listId, 53 | problem_id: problemId, 54 | memo, 55 | }), 56 | }); 57 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/Internal/VirtualContest/DraggableContestConfigProblemTable/ContestCell.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ContestLink } from "../../../../components/ContestLink"; 3 | import Contest from "../../../../interfaces/Contest"; 4 | 5 | interface ContestCellProps { 6 | contest?: Contest; 7 | } 8 | export const ContestCell: React.FC = ({ contest }) => { 9 | return ( 10 | 11 | {contest && } 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/Internal/VirtualContest/DraggableContestConfigProblemTable/DeleteCell.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "reactstrap"; 3 | 4 | interface DeleteCellProps { 5 | onDelete: () => void; 6 | } 7 | export const DeleteCell: React.FC = ({ onDelete }) => { 8 | return ( 9 | 10 | 54 | ))} 55 | 56 | 57 |
58 | {languages.map((language) => ( 59 | 60 | ))} 61 |
62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/RecentSubmissions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Alert, Row, Spinner } from "reactstrap"; 3 | import { useRecentSubmissions } from "../api/APIClient"; 4 | import { SubmissionListTable } from "../components/SubmissionListTable"; 5 | 6 | export const RecentSubmissions = () => { 7 | const submissionsResponse = useRecentSubmissions(); 8 | 9 | if (!submissionsResponse.data && !submissionsResponse.error) { 10 | return ; 11 | } else if (!submissionsResponse.data) { 12 | return Failed to fetch submission info.; 13 | } 14 | 15 | const submissions = submissionsResponse.data.sort((a, b) => b.id - a.id); 16 | 17 | return ( 18 | 19 |

Recent Submissions

20 | 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/ShortRanking.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useShortRanking } from "../api/APIClient"; 3 | import { Ranking } from "../components/Ranking"; 4 | export const ShortRanking = () => { 5 | const ranking = useShortRanking(); 6 | return ; 7 | }; 8 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/StreakRanking.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Badge, UncontrolledTooltip } from "reactstrap"; 3 | import { useStreakRanking, useUserStreakRank } from "../api/APIClient"; 4 | import { DEFAULT_RANKING_RANGE, RemoteRanking } from "../components/Ranking"; 5 | 6 | export const StreakRanking = () => { 7 | const [range, setRange] = useState(DEFAULT_RANKING_RANGE); 8 | const ranking = useStreakRanking(range.from, range.to).data ?? []; 9 | const firstUser = ranking.length === 0 ? "" : ranking[0].user_id; 10 | const firstRankOnPage = useUserStreakRank(firstUser).data?.rank ?? 0; 11 | return ( 12 | 15 | Streak Ranking{" "} 16 | 17 | ? 18 | 19 | 20 | The streak ranking is based on Japan Standard Time{" "} 21 | (JST, UTC+9). 22 | 23 | 24 | } 25 | rankingSize={1000} 26 | ranking={ranking} 27 | firstRankOnPage={firstRankOnPage} 28 | onChangeRange={setRange} 29 | /> 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/SumRanking.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useSumRanking, useUserSumRank } from "../api/APIClient"; 3 | import { DEFAULT_RANKING_RANGE, RemoteRanking } from "../components/Ranking"; 4 | 5 | export const SumRanking = () => { 6 | const [range, setRange] = useState(DEFAULT_RANKING_RANGE); 7 | const ranking = useSumRanking(range.from, range.to).data ?? []; 8 | const firstUser = ranking.length === 0 ? "" : ranking[0].user_id; 9 | const firstRankOnPage = useUserSumRank(firstUser).data?.rank ?? 0; 10 | 11 | return ( 12 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/TablePage/ContestTable.test.tsx: -------------------------------------------------------------------------------- 1 | import { convertProblemIndexForSorting } from "./ContestTable"; 2 | 3 | describe("Convert a problem index", () => { 4 | it("No number", () => { 5 | const index = "J"; 6 | expect(convertProblemIndexForSorting(index)).toMatchObject(["J", NaN]); 7 | }); 8 | 9 | it("No string", () => { 10 | const index = "1000000007"; 11 | expect(convertProblemIndexForSorting(index)).toMatchObject([ 12 | "", 13 | 1000000007, 14 | ]); 15 | }); 16 | 17 | it("Both exists", () => { 18 | const index = "EX1"; 19 | expect(convertProblemIndexForSorting(index)).toMatchObject(["EX", 1]); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/TablePage/TableTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { Row, ButtonGroup, Button } from "reactstrap"; 3 | import { 4 | ContestCategories, 5 | ContestCategory, 6 | } from "../../utils/ContestClassifier"; 7 | import { isLikeContestCategory } from "../../utils/LikeContestUtils"; 8 | 9 | interface Props { 10 | active: ContestCategory; 11 | setActive: (next: ContestCategory) => void; 12 | mergeLikeContest: boolean; 13 | } 14 | 15 | export const TableTabButtons: React.FC = (props) => { 16 | const { active, setActive, mergeLikeContest } = props; 17 | 18 | const filteredCategories = useMemo(() => { 19 | return ContestCategories.filter( 20 | (category) => !mergeLikeContest || !isLikeContestCategory(category) 21 | ); 22 | }, [mergeLikeContest]); 23 | 24 | return ( 25 | 26 | 27 | {filteredCategories.map((category) => ( 28 | 38 | ))} 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/TrainingPage/LoginAdvice.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Alert } from "reactstrap"; 3 | import { Link } from "react-router-dom"; 4 | import { UserResponse } from "../Internal/types"; 5 | import { ACCOUNT_INFO } from "../../utils/RouterPath"; 6 | import { useLoginLink } from "../../utils/Url"; 7 | 8 | interface Props { 9 | user: UserResponse | undefined; 10 | loading: boolean; 11 | } 12 | export const LoginAdvice: React.FC = (props) => { 13 | const loginLink = useLoginLink(); 14 | 15 | if (props.loading) { 16 | return Loading user info...; 17 | } 18 | if (!props.user) { 19 | return ( 20 | 21 | Login to record your progress. 22 | 23 | ); 24 | } 25 | if (!props.user.atcoder_user_id) { 26 | return ( 27 | 28 | Set your AtCoder ID. 29 | 30 | ); 31 | } 32 | 33 | return ( 34 | Training as {props.user.atcoder_user_id} 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/PieChartBlock/SmallPieChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { SinglePieChart } from "../../../components/SinglePieChart"; 3 | 4 | const COLORS = { 5 | Accepted: "#32cd32", 6 | Trying: "#58616a", 7 | Rejected: "#fd9", 8 | }; 9 | 10 | interface SmallPieChartProps { 11 | title: string; 12 | trying: number; 13 | accepted: number; 14 | rejected: number; 15 | } 16 | 17 | export const SmallPieChart: React.FC = ({ 18 | title, 19 | trying, 20 | rejected, 21 | accepted, 22 | }) => { 23 | const data = [ 24 | { value: accepted, color: COLORS.Accepted, name: "AC" }, 25 | { value: rejected, color: COLORS.Rejected, name: "Non-AC" }, 26 | { value: trying, color: COLORS.Trying, name: "NoSub" }, 27 | ]; 28 | const totalCount = trying + rejected + accepted; 29 | return ( 30 |
31 | 32 |
{title}
33 |
{`${accepted} / ${totalCount}`}
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/ProgressChartBlock/ClimbingLineChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Row } from "reactstrap"; 3 | import { 4 | XAxis, 5 | YAxis, 6 | CartesianGrid, 7 | Tooltip, 8 | LineChart, 9 | Line, 10 | ResponsiveContainer, 11 | } from "recharts"; 12 | import { formatMomentDate, parseSecond } from "../../../utils/DateUtil"; 13 | 14 | interface Props { 15 | climbingData: { dateSecond: number; count: number }[]; 16 | } 17 | 18 | export const ClimbingLineChart: React.FC = (props) => ( 19 | 20 | 21 | 30 | 31 | 36 | formatMomentDate(parseSecond(dateSecond)) 37 | } 38 | /> 39 | 40 | 42 | typeof dateSecond === "number" 43 | ? formatMomentDate(parseSecond(dateSecond)) 44 | : dateSecond 45 | } 46 | /> 47 | 48 | 49 | 50 | 51 | ); 52 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/ProgressChartBlock/DailyEffortBarChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Row } from "reactstrap"; 3 | import { 4 | BarChart, 5 | Bar, 6 | XAxis, 7 | YAxis, 8 | CartesianGrid, 9 | Tooltip, 10 | ResponsiveContainer, 11 | } from "recharts"; 12 | import { formatMomentDate, parseSecond } from "../../../utils/DateUtil"; 13 | 14 | interface Props { 15 | dailyData: { dateSecond: number; count: number }[]; 16 | yRange: number | "auto"; 17 | } 18 | 19 | export const DailyEffortBarChart: React.FC = (props) => ( 20 | 21 | 22 | 31 | 32 | 37 | formatMomentDate(parseSecond(dateSecond)) 38 | } 39 | /> 40 | 41 | 43 | typeof dateSecond === "string" 44 | ? dateSecond 45 | : formatMomentDate(parseSecond(dateSecond)) 46 | } 47 | /> 48 | 49 | 50 | 51 | 52 | ); 53 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/ProgressChartBlock/DailyEffortStackedBarChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Row } from "reactstrap"; 3 | import { 4 | BarChart, 5 | Bar, 6 | XAxis, 7 | YAxis, 8 | CartesianGrid, 9 | Tooltip, 10 | ResponsiveContainer, 11 | } from "recharts"; 12 | import { 13 | getRatingColorCode, 14 | RatingColor, 15 | RatingColors, 16 | mapToObject, 17 | } from "../../../utils"; 18 | import { formatMomentDate, parseSecond } from "../../../utils/DateUtil"; 19 | import { DailyEffortTooltip } from "./DailyEffortTooltip"; 20 | 21 | interface Props { 22 | dailyColorCount: { 23 | dateSecond: number; 24 | countMap: Map; 25 | }[]; 26 | yRange: number | "auto"; 27 | } 28 | 29 | export const DailyEffortStackedBarChart: React.FC = (props) => { 30 | const { dailyColorCount, yRange } = props; 31 | 32 | const dailyCount = dailyColorCount.map(({ dateSecond, countMap }) => { 33 | return { ...mapToObject(countMap), dateSecond }; 34 | }); 35 | 36 | return ( 37 | 38 | 39 | 48 | 49 | 54 | formatMomentDate(parseSecond(dateSecond)) 55 | } 56 | /> 57 | 58 | 59 | 60 | {RatingColors.map((ratingColor) => { 61 | const color = getRatingColorCode(ratingColor); 62 | return ( 63 | 70 | ); 71 | })} 72 | 73 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/ProgressChartBlock/FilteringHeatmap.test.tsx: -------------------------------------------------------------------------------- 1 | import Submission from "../../../interfaces/Submission"; 2 | import { filterSubmissions } from "./FilteringHeatmap"; 3 | 4 | const emptySubmission: Submission = { 5 | execution_time: null, 6 | point: 0.0, 7 | result: "", 8 | problem_id: "", 9 | user_id: "", 10 | epoch_second: 0, 11 | contest_id: "", 12 | id: 0, 13 | language: "", 14 | length: 0, 15 | }; 16 | const submissions = [ 17 | { 18 | ...emptySubmission, 19 | id: 1, 20 | result: "AC", 21 | problem_id: "problem_1", 22 | }, 23 | { 24 | ...emptySubmission, 25 | id: 2, 26 | result: "AC", 27 | problem_id: "problem_1", 28 | }, 29 | ]; 30 | 31 | describe("filter", () => { 32 | it("unique ac submissions", () => { 33 | const filtered = filterSubmissions(submissions, "Unique AC"); 34 | expect(filtered.length).toBe(1); 35 | expect(filtered[0].id).toBe(1); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/ProgressChartBlock/TeeChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Row } from "reactstrap"; 3 | import { 4 | XAxis, 5 | YAxis, 6 | CartesianGrid, 7 | Tooltip, 8 | LineChart, 9 | Line, 10 | ResponsiveContainer, 11 | } from "recharts"; 12 | import { formatMomentDate, parseSecond } from "../../../utils/DateUtil"; 13 | 14 | interface Props { 15 | climbingData: { dateSecond: number; count: number }[]; 16 | } 17 | 18 | export const TeeChart: React.FC = (props) => ( 19 | 20 | 21 | 30 | 31 | 36 | formatMomentDate(parseSecond(dateSecond)) 37 | } 38 | /> 39 | 40 | 42 | typeof dateSecond === "number" 43 | ? formatMomentDate(parseSecond(dateSecond)) 44 | : dateSecond 45 | } 46 | /> 47 | 48 | 49 | 50 | 51 | ); 52 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/Submissions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Row } from "reactstrap"; 3 | import { useRatingInfo, useUserSubmission } from "../../api/APIClient"; 4 | import { SubmissionListTable } from "../../components/SubmissionListTable"; 5 | 6 | interface Props { 7 | userId: string; 8 | } 9 | 10 | export const Submissions: React.FC = (props) => { 11 | const submissions = useUserSubmission(props.userId) ?? []; 12 | const ratingInfo = useRatingInfo(props.userId); 13 | 14 | return ( 15 | 16 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/TrophyBlock/ACProblemsTrophyGenerator.ts: -------------------------------------------------------------------------------- 1 | import { isAccepted } from "../../../utils"; 2 | import { Trophy, TrophySubmission } from "./Trophy"; 3 | 4 | const generateACTrophiesWithOneProblem = ( 5 | acProblemIds: Set 6 | ): Trophy[] => { 7 | const milestones: [string, string, string][] = [ 8 | ["2718", "Solved WorldTourFinals2019-E", "wtf19_e"], 9 | ]; 10 | return milestones.map(([title, reason, problem_id]) => { 11 | const achieved = acProblemIds.has(problem_id); 12 | const sortId = `accepted-problem-${problem_id}`; 13 | return { title, reason, achieved, sortId, group: "Problems" }; 14 | }); 15 | }; 16 | 17 | const uniqueACProblemIds = (submissions: TrophySubmission[]): Set => { 18 | const acProblemIds = submissions 19 | .filter((submissions) => isAccepted(submissions.submission.result)) 20 | .map((submission) => submission.submission.problem_id); 21 | return new Set(acProblemIds); 22 | }; 23 | 24 | export const generateACProblemsTrophies = ( 25 | allSubmissions: TrophySubmission[] 26 | ): Trophy[] => { 27 | const trophies = [] as Trophy[]; 28 | const acProblemIds = uniqueACProblemIds(allSubmissions); 29 | trophies.push(...generateACTrophiesWithOneProblem(acProblemIds)); 30 | return trophies; 31 | }; 32 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/TrophyBlock/CompleteContestTrophyGenerator.ts: -------------------------------------------------------------------------------- 1 | import { isAccepted } from "../../../utils"; 2 | import Contest from "../../../interfaces/Contest"; 3 | import Problem from "../../../interfaces/Problem"; 4 | import { ContestId, ProblemId } from "../../../interfaces/Status"; 5 | import { Trophy, TrophySubmission } from "./Trophy"; 6 | 7 | const isCompleteContest = ( 8 | contestId: ContestId, 9 | contestToProblems: Map, 10 | solvedProblemIdSet: Set 11 | ): boolean => { 12 | const problems = contestToProblems.get(contestId); 13 | return ( 14 | !!problems && 15 | problems.every((problem) => solvedProblemIdSet.has(problem.id)) 16 | ); 17 | }; 18 | 19 | const generateCompleteContestTrophy = ( 20 | contestId: ContestId, 21 | contestTitle: string, 22 | contestToProblems: Map, 23 | solvedProblemIdSet: Set 24 | ): Trophy => { 25 | const title = `Completed ${contestTitle}`; 26 | const reason = `Solved all problems in ${contestId}`; 27 | const achieved = isCompleteContest( 28 | contestId, 29 | contestToProblems, 30 | solvedProblemIdSet 31 | ); 32 | const sortId = `complete-contest-${contestId}`; 33 | return { title, reason, achieved, sortId, group: "Contests" }; 34 | }; 35 | 36 | const uniqueACProblemIds = (submissions: TrophySubmission[]): Set => { 37 | const acProblemIds = submissions 38 | .filter((submissions) => isAccepted(submissions.submission.result)) 39 | .map((submission) => submission.submission.problem_id); 40 | return new Set(acProblemIds); 41 | }; 42 | 43 | export const generateCompleteContestTrophies = ( 44 | allSubmissions: TrophySubmission[], 45 | contests: Map, 46 | contestToProblems: Map 47 | ): Trophy[] => { 48 | const trophies = [] as Trophy[]; 49 | const solvedProblemIdSet = uniqueACProblemIds(allSubmissions); 50 | 51 | Array.from(contests).forEach(([contestId, contest]) => { 52 | trophies.push( 53 | generateCompleteContestTrophy( 54 | contestId, 55 | contest.title, 56 | contestToProblems, 57 | solvedProblemIdSet 58 | ) 59 | ); 60 | }); 61 | return trophies; 62 | }; 63 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/TrophyBlock/Trophy.ts: -------------------------------------------------------------------------------- 1 | import ProblemModel from "../../../interfaces/ProblemModel"; 2 | import Submission from "../../../interfaces/Submission"; 3 | 4 | export interface Trophy { 5 | title: string; 6 | reason: string; 7 | achieved: boolean; 8 | sortId: string; 9 | group: TrophyGroup; 10 | } 11 | 12 | export const TrophyGroups = [ 13 | "AC Count", 14 | "Contests", 15 | "Problems", 16 | "Streak", 17 | ] as const; 18 | export type TrophyGroup = typeof TrophyGroups[number]; 19 | 20 | export interface TrophySubmission { 21 | submission: Submission; 22 | problemModel: ProblemModel | undefined; 23 | } 24 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/pages/UserPage/UserUtils.ts: -------------------------------------------------------------------------------- 1 | import { isAccepted } from "../../utils"; 2 | import { ProblemId } from "../../interfaces/Status"; 3 | import Submission from "../../interfaces/Submission"; 4 | 5 | export const solvedProblemIds = ( 6 | submissions: Map 7 | ): ProblemId[] => 8 | Array.from(submissions.entries()) 9 | .filter(([, submissionList]) => 10 | submissionList.find((submission) => isAccepted(submission.result)) 11 | ) 12 | .map(([problemId]) => problemId); 13 | 14 | export const solvedProblemIdsFromArray = (submissions: Submission[]) => { 15 | const accepted = submissions.filter((s) => isAccepted(s.result)); 16 | const problemIds = new Set(accepted.map((s) => s.problem_id)); 17 | return Array.from(problemIds); 18 | }; 19 | 20 | export const rejectedProblemIdsFromArray = (submissions: Submission[]) => { 21 | const accepted = submissions.filter((s) => isAccepted(s.result)); 22 | const acceptedProblemIds = new Set(accepted.map((s) => s.problem_id)); 23 | 24 | const rejected = submissions.filter((s) => !isAccepted(s.result)); 25 | const rejectedProblemIds = new Set(rejected.map((s) => s.problem_id)); 26 | 27 | const difference = new Set( 28 | Array.from(rejectedProblemIds).filter((e) => !acceptedProblemIds.has(e)) 29 | ); 30 | 31 | return Array.from(difference); 32 | }; 33 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare namespace NodeJS { 3 | interface ProcessEnv { 4 | NODE_ENV: "development" | "production" | "test"; 5 | PUBLIC_URL: string; 6 | REACT_APP_INTERNAL_API_URL: string; 7 | REACT_APP_ATCODER_API_URL: string; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require("http-proxy-middleware"); // eslint-disable-line @typescript-eslint/no-var-requires 2 | 3 | module.exports = function (app) { 4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access 5 | app.use( 6 | createProxyMiddleware("/atcoder-api", { 7 | target: "https://kenkoooo.com", 8 | changeOrigin: true, 9 | pathRewrite: { 10 | "^/atcoder-api": "/atcoder/atcoder-api", 11 | }, 12 | }) 13 | ); 14 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access 15 | app.use( 16 | createProxyMiddleware("/internal-api", { 17 | target: "https://kenkoooo.com", 18 | changeOrigin: true, 19 | pathRewrite: { 20 | "^/internal-api": "/atcoder/internal-api", 21 | }, 22 | }) 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/style/_common.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | @import "react-bs-table"; 3 | 4 | @import "custom"; 5 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/style/_theme-light.scss: -------------------------------------------------------------------------------- 1 | @import "common"; 2 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/style/index.scss: -------------------------------------------------------------------------------- 1 | .theme-light { 2 | @import "theme-light"; 3 | } 4 | 5 | .theme-dark { 6 | @import "theme-dark"; 7 | } 8 | 9 | .theme-purple { 10 | @import "theme-purple"; 11 | } 12 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/style/theme.ts: -------------------------------------------------------------------------------- 1 | export const ThemeLight = { 2 | difficultyBlackColor: "#404040", 3 | difficultyGreyColor: "#808080", 4 | difficultyBrownColor: "#804000", 5 | difficultyGreenColor: "#008000", 6 | difficultyCyanColor: "#00C0C0", 7 | difficultyBlueColor: "#0000FF", 8 | difficultyYellowColor: "#C0C000", 9 | difficultyOrangeColor: "#FF8000", 10 | difficultyRedColor: "#FF0000", 11 | }; 12 | 13 | export const ThemeDark: typeof ThemeLight = { 14 | ...ThemeLight, 15 | difficultyBlackColor: "#FFFFFF", 16 | difficultyGreyColor: "#C0C0C0", 17 | difficultyBrownColor: "#B08C56", 18 | difficultyGreenColor: "#3FAF3F", 19 | difficultyCyanColor: "#42E0E0", 20 | difficultyBlueColor: "#8888FF", 21 | difficultyYellowColor: "#FFFF56", 22 | difficultyOrangeColor: "#FFB836", 23 | difficultyRedColor: "#FF6767", 24 | }; 25 | 26 | export const ThemePurple = ThemeLight; 27 | 28 | export type Theme = typeof ThemeLight; 29 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/Api.tsx: -------------------------------------------------------------------------------- 1 | import { UserId } from "../interfaces/Status"; 2 | import Submission, { isSubmission } from "../interfaces/Submission"; 3 | import { hasPropertyAsType, isNumber } from "./TypeUtils"; 4 | 5 | const ATCODER_API_URL = process.env.REACT_APP_ATCODER_API_URL; 6 | 7 | export const fetchPartialUserSubmissions = async ( 8 | userId: UserId, 9 | fromSecond: number 10 | ): Promise => { 11 | if (userId.length === 0) { 12 | return []; 13 | } 14 | const url = `${ATCODER_API_URL}/v3/user/submissions?user=${userId}&from_second=${fromSecond}`; 15 | const response = await fetch(url); 16 | 17 | const json: unknown = await response.json(); 18 | if (!Array.isArray(json)) { 19 | return []; 20 | } 21 | return json.filter(isSubmission); 22 | }; 23 | 24 | export const fetchUserSubmissionCount = async ( 25 | userId: string, 26 | fromSecond: number, 27 | toSecond: number 28 | ) => { 29 | const url = `${ATCODER_API_URL}/v3/user/submission_count?user=${userId}&from_second=${fromSecond}&to_second=${toSecond}`; 30 | const response = await fetch(url); 31 | const json: unknown = await response.json(); 32 | if (hasPropertyAsType(json, "count", isNumber)) { 33 | return json.count; 34 | } else { 35 | return 0; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/Chunk.ts: -------------------------------------------------------------------------------- 1 | export const toChunks = (array: T[], size: number) => { 2 | let chunk = [] as T[]; 3 | const chunks = [] as T[][]; 4 | for (const element of array) { 5 | chunk.push(element); 6 | if (chunk.length === size) { 7 | chunks.push(chunk); 8 | chunk = []; 9 | } 10 | } 11 | if (chunk.length > 0) { 12 | chunks.push(chunk); 13 | } 14 | return chunks; 15 | }; 16 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/DateUtil.test.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import { getNextSunday } from "./DateUtil"; 3 | 4 | describe("get this Sunday", () => { 5 | it("when it's Thursday", () => { 6 | const thursday = moment("2019-08-15T14:24:00"); 7 | const sunday = getNextSunday(thursday); 8 | expect(sunday.date()).toBe(18); 9 | }); 10 | 11 | it("when it's Sunday", () => { 12 | const sunday = moment("2019-08-18T14:24:00"); 13 | expect(getNextSunday(sunday).date()).toBe(25); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/DateUtil.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | const DATE_FORMAT = "YYYY-MM-DD"; 4 | const DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss"; 5 | const DATETIME_DAY_FORMAT = "YYYY-MM-DD HH:mm:ss (ddd)"; 6 | 7 | export const parseSecond = (epochSecond: number): moment.Moment => 8 | moment.unix(epochSecond); 9 | 10 | export const parseDateLabel = (dateLabel: string): moment.Moment => 11 | moment(dateLabel, DATE_FORMAT); 12 | 13 | export const formatMomentDate = (t: moment.Moment): string => 14 | t.format(DATE_FORMAT); 15 | export const formatMomentDateTime = (t: moment.Moment): string => 16 | t.format(DATETIME_FORMAT); 17 | export const formatMomentDateTimeDay = (t: moment.Moment): string => 18 | t.format(DATETIME_DAY_FORMAT); 19 | 20 | export const getNextSunday = (t: moment.Moment): moment.Moment => { 21 | const date = t.date(); 22 | const weekday = t.weekday(); 23 | return t.date(date + 7 - weekday); 24 | }; 25 | 26 | export const getToday = (): moment.Moment => moment(); 27 | export const formatDuration = (durationSecond: number): string => { 28 | const hours = Math.floor(durationSecond / 3600); 29 | const minutes = Math.floor(durationSecond / 60) - hours * 60; 30 | const seconds = durationSecond - hours * 3600 - minutes * 60; 31 | 32 | const mm = minutes < 10 ? `0${minutes}` : minutes.toString(); 33 | const ss = seconds < 10 ? `0${seconds}` : seconds.toString(); 34 | return `${hours}:${mm}:${ss}`; 35 | }; 36 | 37 | export const getCurrentUnixtimeInSecond = () => Math.floor(Date.now() / 1000); 38 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/GroupBy.test.ts: -------------------------------------------------------------------------------- 1 | import { groupBy } from "./GroupBy"; 2 | 3 | test("group by", () => { 4 | const array = [ 5 | { group: 1, value: 1 }, 6 | { group: 2, value: 2 }, 7 | { group: 2, value: 3 }, 8 | { group: 1, value: 4 }, 9 | ]; 10 | 11 | const map = groupBy(array, (item) => item.group); 12 | expect(map.get(1)?.some((item) => item.value === 1)).toBe(true); 13 | expect(map.get(1)?.some((item) => item.value === 4)).toBe(true); 14 | expect(map.get(2)?.some((item) => item.value === 2)).toBe(true); 15 | expect(map.get(2)?.some((item) => item.value === 3)).toBe(true); 16 | }); 17 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/GroupBy.ts: -------------------------------------------------------------------------------- 1 | export function groupBy( 2 | array: T[], 3 | selector: (item: T) => K 4 | ): Map { 5 | const result = new Map(); 6 | array.forEach((item) => { 7 | const key = selector(item); 8 | const list = result.get(key) ?? []; 9 | list.push(item); 10 | result.set(key, list); 11 | }); 12 | return result; 13 | } 14 | export function countBy( 15 | array: T[], 16 | selector: (item: T) => K 17 | ): Map { 18 | const grouped = groupBy(array, selector); 19 | const count: [K, number][] = Array.from( 20 | grouped.entries() 21 | ).map(([key, group]) => [key, group.length]); 22 | return new Map(count); 23 | } 24 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/ImmutableMigration.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Since Immutable.js is no longer updated, 3 | * let's migrate from them to the standard libraries. 4 | * */ 5 | 6 | import { List, Map as ImmutableMap } from "immutable"; 7 | 8 | export function convertMap(immutableMap: ImmutableMap): Map { 9 | return immutableMap.entrySeq().reduce((map, [key, value]) => { 10 | map.set(key, value); 11 | return map; 12 | }, new Map()); 13 | } 14 | 15 | export function convertMapOfLists( 16 | immutableMapOfLists: ImmutableMap> 17 | ): Map { 18 | return convertMap(immutableMapOfLists.map((list) => list.toArray())); 19 | } 20 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/LanguageNormalizer.test.ts: -------------------------------------------------------------------------------- 1 | import { normalizeLanguage } from "./LanguageNormalizer"; 2 | 3 | test("normalize language", () => { 4 | expect(normalizeLanguage("Perl (v5.18.2)")).toBe("Perl"); 5 | expect(normalizeLanguage("Perl6 (rakudo-star 2016.01)")).toBe("Raku"); 6 | expect(normalizeLanguage("Rust (1.42.0)")).toBe("Rust"); 7 | expect(normalizeLanguage("C++11 (Clang++ 3.4)")).toBe("C++"); 8 | expect(normalizeLanguage("Scala (2.11.7)")).toBe("Scala"); 9 | expect(normalizeLanguage("Fortran(GNU Fortran 9.2.1)")).toBe("Fortran"); 10 | expect(normalizeLanguage("Ada2012 (GNAT 9.2.1)")).toBe("Ada"); 11 | expect(normalizeLanguage("Haxe (4.0.3); js")).toBe("Haxe"); 12 | expect(normalizeLanguage("C++11 (Clang++ 3.4)")).toBe("C++"); 13 | expect(normalizeLanguage("C++ 20 (gcc 12.2)")).toBe("C++"); 14 | expect(normalizeLanguage("C# 11.0 (.NET 7.0.7)")).toBe("C#"); 15 | expect(normalizeLanguage("C# 11.0 AOT (.NET 7.0.7)")).toBe("C#"); 16 | expect(normalizeLanguage("Visual Basic 16.9 (...)")).toBe("Visual Basic"); 17 | expect(normalizeLanguage("><> (fishr 0.1.0)")).toBe("><>"); 18 | expect(normalizeLanguage("プロデル (...)")).toBe("プロデル"); 19 | 20 | // mapped individually 21 | expect(normalizeLanguage("Assembly x64")).toBe("Assembly x64"); 22 | expect(normalizeLanguage("Awk (GNU Awk 4.1.4)")).toBe("AWK"); 23 | expect(normalizeLanguage("IOI-Style C++ (GCC 5.4.1)")).toBe("C++"); 24 | expect(normalizeLanguage("LuaJIT (2.0.4)")).toBe("Lua"); 25 | expect(normalizeLanguage("Objective-C (Clang3.8.0)")).toBe("Objective-C"); 26 | expect(normalizeLanguage("PyPy2 (7.3.0)")).toBe("Python"); 27 | expect(normalizeLanguage("Python (Cython 0.29.34)")).toBe("Cython"); 28 | expect(normalizeLanguage("Cython (0.29.16)")).toBe("Cython"); 29 | expect(normalizeLanguage("Seed7 (Seed7 3.2.1)")).toBe("Seed7"); 30 | 31 | expect(normalizeLanguage("1234")).toBe("Unknown"); 32 | }); 33 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/LanguageNormalizer.ts: -------------------------------------------------------------------------------- 1 | const mapping: [string, string][] = [ 2 | ["PyPy", "Python"], 3 | ["Python (Cython", "Cython"], 4 | ["Assembly x64", "Assembly x64"], 5 | ["Awk", "AWK"], 6 | ["IOI-Style", "C++"], 7 | ["LuaJIT", "Lua"], 8 | ["Seed7", "Seed7"], 9 | ["Perl6", "Raku"], 10 | ["Objective-C", "Objective-C"], 11 | ]; 12 | 13 | export const normalizeLanguage = (language: string): string => { 14 | for (const [beginning, normalized] of mapping) { 15 | if (language.startsWith(beginning)) { 16 | return normalized; 17 | } 18 | } 19 | 20 | return language.replace(/\s*[\d(-].*/, "") || "Unknown"; 21 | }; 22 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/LastSolvedTime.test.ts: -------------------------------------------------------------------------------- 1 | import { getLastSolvedTimeMap } from "./LastSolvedTime"; 2 | 3 | test("get last solved time map", () => { 4 | const submission1 = { 5 | execution_time: 1, 6 | point: 100, 7 | result: "AC", 8 | problem_id: "problem1", 9 | user_id: "user1", 10 | epoch_second: 0, 11 | contest_id: "contest1", 12 | id: 0, 13 | language: "Python", 14 | length: 1, 15 | }; 16 | const submission2 = { 17 | execution_time: 1, 18 | point: 100, 19 | result: "AC", 20 | problem_id: "problem1", 21 | user_id: "user2", 22 | epoch_second: 1, 23 | contest_id: "contest1", 24 | id: 1, 25 | language: "Python", 26 | length: 1, 27 | }; 28 | const submission3 = { 29 | execution_time: 1, 30 | point: 100, 31 | result: "WA", 32 | problem_id: "problem2", 33 | user_id: "user2", 34 | epoch_second: 0, 35 | contest_id: "contest1", 36 | id: 2, 37 | language: "Python", 38 | length: 1, 39 | }; 40 | const submission4 = { 41 | execution_time: 2, 42 | point: 200, 43 | result: "AC", 44 | problem_id: "problem3", 45 | user_id: "user1", 46 | epoch_second: 0, 47 | contest_id: "contest2", 48 | id: 3, 49 | language: "Rust", 50 | length: 1, 51 | }; 52 | const submission5 = { 53 | execution_time: 2, 54 | point: 200, 55 | result: "TLE", 56 | problem_id: "problem3", 57 | user_id: "user3", 58 | epoch_second: 5, 59 | contest_id: "contest2", 60 | id: 3, 61 | language: "Python", 62 | length: 1, 63 | }; 64 | 65 | const submissions = [ 66 | submission1, 67 | submission2, 68 | submission3, 69 | submission4, 70 | submission5, 71 | ]; 72 | const map = getLastSolvedTimeMap(submissions); 73 | expect(map.get("problem1")).toBe(1); 74 | expect(map.has("problem2")).toBe(false); 75 | expect(map.get("problem3")).toBe(0); 76 | }); 77 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/LikeContestUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getLikeContestCategory, 3 | isLikeContestCategory, 4 | } from "./LikeContestUtils"; 5 | import { ContestCategory } from "./ContestClassifier"; 6 | 7 | type GetLikeContestTestType = [ContestCategory, ContestCategory | undefined]; 8 | test.each([ 9 | ["ABC", "ABC-Like"], 10 | ["ARC", "ARC-Like"], 11 | ["AGC", "AGC-Like"], 12 | ["ABC-Like", undefined], 13 | ["ARC-Like", undefined], 14 | ["AGC-Like", undefined], 15 | ["PAST", undefined], 16 | ["JOI", undefined], 17 | ["JAG", undefined], 18 | ["AHC", undefined], 19 | ["Marathon", undefined], 20 | ["Other Sponsored", undefined], 21 | ["Other Contests", undefined], 22 | ])("Get Like Contest Category", (contest, result) => { 23 | expect(getLikeContestCategory(contest)).toBe(result); 24 | }); 25 | 26 | type IsLikeContestCategoryTestType = [ContestCategory, boolean]; 27 | test.each([ 28 | ["ABC", false], 29 | ["ARC", false], 30 | ["AGC", false], 31 | ["ABC-Like", true], 32 | ["ARC-Like", true], 33 | ["AGC-Like", true], 34 | ["PAST", false], 35 | ["JOI", false], 36 | ["JAG", false], 37 | ["AHC", false], 38 | ["Marathon", false], 39 | ["Other Sponsored", false], 40 | ["Other Contests", false], 41 | ])("Is Like Contest Category", (contest, result) => { 42 | expect(isLikeContestCategory(contest)).toBe(result); 43 | }); 44 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/LikeContestUtils.ts: -------------------------------------------------------------------------------- 1 | import { ContestCategory } from "./ContestClassifier"; 2 | 3 | export const getLikeContestCategory = ( 4 | contestCategory: ContestCategory 5 | ): ContestCategory | undefined => { 6 | switch (contestCategory) { 7 | case "ABC": 8 | return "ABC-Like"; 9 | case "ARC": 10 | return "ARC-Like"; 11 | case "AGC": 12 | return "AGC-Like"; 13 | default: 14 | break; 15 | } 16 | }; 17 | 18 | const LikeContestCategories: readonly ContestCategory[] = [ 19 | "ABC-Like", 20 | "ARC-Like", 21 | "AGC-Like", 22 | ]; 23 | export const isLikeContestCategory = (category: ContestCategory) => { 24 | return LikeContestCategories.includes(category); 25 | }; 26 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/LocalStorage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Restrict the key of LocalStorage to avoid conflicting 4 | const LocalStorageKeys = [ 5 | "theme", 6 | "contestTableTab", 7 | "showDifficulty", 8 | "colorMode", 9 | "showPenalties", 10 | "hideCompletedContest", 11 | "dailyEffortBarChartActiveTab", 12 | "dailyEffortYRange", 13 | "climbingLineChartActiveTab", 14 | "climbingLineChartReverseColorOrder", 15 | "pinMe", 16 | "showRating", 17 | "recommendOption", 18 | "recommendExperimental", 19 | "recoomendExcludeOption", 20 | "MergeLikeContest", 21 | ] as const; 22 | type LocalStorageKey = typeof LocalStorageKeys[number]; 23 | 24 | export function useLocalStorage( 25 | key: LocalStorageKey, 26 | defaultValue: T 27 | ): [T, React.Dispatch>] { 28 | const a = localStorage.getItem(key); 29 | const [value, setValue] = React.useState( 30 | a ? (JSON.parse(a) as T) : defaultValue 31 | ); 32 | 33 | React.useEffect(() => { 34 | localStorage.setItem(key, JSON.stringify(value)); 35 | }, [value, key]); 36 | 37 | return [value, setValue]; 38 | } 39 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/ProblemModelUtil.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProblemModelWithDifficultyModel, 3 | ProblemModelWithTimeModel, 4 | } from "../interfaces/ProblemModel"; 5 | 6 | export const predictSolveProbability = ( 7 | problemModel: ProblemModelWithDifficultyModel, 8 | internalRating: number 9 | ): number => { 10 | return ( 11 | 1 / 12 | (1 + 13 | Math.exp( 14 | -problemModel.discrimination * 15 | (internalRating - problemModel.rawDifficulty) 16 | )) 17 | ); 18 | }; 19 | 20 | export const predictSolveTime = ( 21 | problemModel: ProblemModelWithTimeModel, 22 | internalRating: number 23 | ): number => { 24 | // return predicted solve time in second. 25 | const logTime = problemModel.slope * internalRating + problemModel.intercept; 26 | return Math.exp(logTime); 27 | }; 28 | 29 | export const calculateTopPlayerEquivalentEffort = ( 30 | problemModel: ProblemModelWithTimeModel 31 | ): number => { 32 | const topPlayerRating = 4000; 33 | return predictSolveTime(problemModel, topPlayerRating); 34 | }; 35 | 36 | export const formatPredictedSolveTime = ( 37 | predictedSolveTime: number | null 38 | ): string => { 39 | if (predictedSolveTime === null) { 40 | return "-"; 41 | } else if (predictedSolveTime < 30) { 42 | return "<1 min"; 43 | } else { 44 | const minutes = Math.round(predictedSolveTime / 60); 45 | if (minutes > 1) { 46 | return `${minutes} mins`; 47 | } else { 48 | return `${minutes} min`; 49 | } 50 | } 51 | }; 52 | 53 | export const formatPredictedSolveProbability = ( 54 | predictedSolveProbability: number | null 55 | ): string => { 56 | if (predictedSolveProbability === null) { 57 | return "-"; 58 | } else if (predictedSolveProbability < 0.005) { 59 | return "<1%"; 60 | } else if (predictedSolveProbability > 0.995) { 61 | return ">99%"; 62 | } else { 63 | const percents = Math.round(predictedSolveProbability * 100); 64 | return `${percents}%`; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/QueryString.ts: -------------------------------------------------------------------------------- 1 | import { Location } from "history"; 2 | 3 | export const generatePathWithParams = ( 4 | location: Location, 5 | params: Record 6 | ): string => { 7 | const searchParams = new URLSearchParams(location.search); 8 | Object.keys(params).forEach((key) => searchParams.set(key, params[key])); 9 | 10 | return `${location.pathname}?${searchParams.toString()}`; 11 | }; 12 | 13 | export const PROBLEM_ID_SEPARATE_SYMBOL = "~"; 14 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/RatingInfo.ts: -------------------------------------------------------------------------------- 1 | import ContestParticipation from "../interfaces/ContestParticipation"; 2 | 3 | export class RatingInfo { 4 | readonly rating: number; 5 | readonly participationCount: number; 6 | readonly internalRating: number | null; 7 | 8 | constructor(rating: number, participationCount: number) { 9 | this.rating = rating; 10 | this.participationCount = participationCount; 11 | 12 | if (participationCount === 0) { 13 | this.internalRating = null; 14 | } else { 15 | let ratingBeforeLowerAdjustment: number; 16 | if (rating <= 400) { 17 | ratingBeforeLowerAdjustment = 400 * (1 - Math.log(400 / rating)); 18 | } else { 19 | ratingBeforeLowerAdjustment = rating; 20 | } 21 | const participationAdjustment = 22 | ((Math.sqrt(1 - 0.9 ** (2 * participationCount)) / 23 | (1 - 0.9 ** participationCount) - 24 | 1) / 25 | (Math.sqrt(19) - 1)) * 26 | 1200; 27 | this.internalRating = 28 | ratingBeforeLowerAdjustment + participationAdjustment; 29 | } 30 | } 31 | } 32 | 33 | export const ratingInfoOf = (contestHistory: ContestParticipation[]) => { 34 | if (contestHistory.length === 0) { 35 | return new RatingInfo(0, 0); 36 | } else { 37 | const latestRating = contestHistory[contestHistory.length - 1].NewRating; 38 | const ratedCount = contestHistory.filter( 39 | (participation) => participation.IsRated 40 | ).length; 41 | return new RatingInfo(latestRating, ratedCount); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/RouterPath.ts: -------------------------------------------------------------------------------- 1 | export const ACCOUNT_INFO = "/login/user"; 2 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/StaticDataStorage.ts: -------------------------------------------------------------------------------- 1 | import { useSWRData } from "../api"; 2 | import { Course } from "../interfaces/Course"; 3 | 4 | const COURSE_PATH = "/atcoder/static_data/courses"; 5 | 6 | const loadCourse = (jsonFilename: string): Promise => { 7 | return fetch(`${COURSE_PATH}/${jsonFilename}`) 8 | .then((response) => response.json()) 9 | .then((response) => response as Course); 10 | }; 11 | 12 | export const useCourses = () => { 13 | return useSWRData("COURSES", () => 14 | Promise.all([loadCourse("boot_camp_for_beginners.json")]) 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/TypeUtils.ts: -------------------------------------------------------------------------------- 1 | const hasProperty = ( 2 | obj: X | null | undefined, 3 | prop: Y 4 | ): obj is X & Record => { 5 | return Object.prototype.hasOwnProperty.call(obj, prop) ?? false; 6 | }; 7 | export const isString = (obj: unknown): obj is string => 8 | typeof obj === "string"; 9 | export const isNumber = (obj: unknown): obj is number => 10 | typeof obj === "number"; 11 | export const isBoolean = (obj: unknown): obj is boolean => 12 | typeof obj === "boolean"; 13 | export const hasPropertyAsType = ( 14 | obj: X | null | undefined, 15 | prop: Y, 16 | check: { (arg: unknown): arg is T } 17 | ): obj is X & Record => { 18 | return hasProperty(obj, prop) && check(obj[prop]); 19 | }; 20 | export const hasPropertyAsTypeOrNull = < 21 | X extends unknown, 22 | Y extends PropertyKey, 23 | T 24 | >( 25 | obj: X | null | undefined, 26 | prop: Y, 27 | check: { (arg: unknown): arg is T } 28 | ): obj is X & Record => { 29 | return hasProperty(obj, prop) && (check(obj[prop]) || obj[prop] === null); 30 | }; 31 | export const hasPropertyAsTypeOrUndefined = < 32 | X extends unknown, 33 | Y extends PropertyKey, 34 | T 35 | >( 36 | obj: X | null | undefined, 37 | prop: Y, 38 | check: { (arg: unknown): arg is T } 39 | ): obj is X & Record => { 40 | return ( 41 | !hasProperty(obj, prop) || 42 | check(obj[prop]) || 43 | typeof obj[prop] === "undefined" 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/Url.tsx: -------------------------------------------------------------------------------- 1 | const BASE_URL = "https://atcoder.jp"; 2 | 3 | const CLIENT_ID = "162a5276634fc8b970f7"; 4 | const AUTHORIZATION_CALLBACK_URL = 5 | "https://kenkoooo.com/atcoder/internal-api/authorize"; 6 | 7 | export const formatContestUrl = (contest: string): string => 8 | `${BASE_URL}/contests/${contest}`; 9 | 10 | export const formatSubmissionUrl = (id: number, contest: string): string => 11 | `${formatContestUrl(contest)}/submissions/${id}`; 12 | 13 | export const formatProblemUrl = (problem: string, contest: string): string => 14 | `${formatContestUrl(contest)}/tasks/${problem}`; 15 | 16 | export const formatSolversUrl = (contest: string, problem: string): string => 17 | `${formatContestUrl(contest)}/submissions?f.Task=${problem}&f.Status=AC`; 18 | 19 | export const formatUserUrl = (userId: string): string => 20 | `https://atcoder.jp/users/${userId}`; 21 | 22 | export const useLoginLink = (): string => { 23 | const currentPath = location.hash.slice(1); 24 | const redirectUri = `${AUTHORIZATION_CALLBACK_URL}?redirect_to=${encodeURIComponent( 25 | currentPath 26 | )}`; 27 | const loginLink = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent( 28 | redirectUri 29 | )}`; 30 | return loginLink; 31 | }; 32 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/UserState.ts: -------------------------------------------------------------------------------- 1 | import { UserResponse } from "../pages/Internal/types"; 2 | 3 | export const isLoggedIn = (loginState: UserResponse | undefined): boolean => 4 | !!loginState && loginState.internal_user_id.length > 0; 5 | 6 | export const loggedInUserId = ( 7 | loginState: UserResponse | undefined 8 | ): string | undefined => 9 | loginState?.atcoder_user_id != null ? loginState?.atcoder_user_id : undefined; 10 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/src/utils/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ATCODER_RIVALS_REGEXP, 3 | ATCODER_USER_REGEXP, 4 | extractRivalsParam, 5 | normalizeUserId, 6 | } from "./index"; 7 | 8 | describe("user regex", () => { 9 | it("should match", () => { 10 | expect(ATCODER_USER_REGEXP.exec("user_1a")).toBeTruthy(); 11 | }); 12 | 13 | it("should not match", () => { 14 | expect(ATCODER_USER_REGEXP.exec("user;")).toBeFalsy(); 15 | }); 16 | }); 17 | 18 | describe("rival regex", () => { 19 | it("should match", () => { 20 | expect(ATCODER_RIVALS_REGEXP.exec(" user1 , USER2, user_3 ")).toBeTruthy(); 21 | }); 22 | 23 | it("empty string should not match", () => { 24 | expect(ATCODER_RIVALS_REGEXP.exec("")).toBeFalsy(); 25 | }); 26 | 27 | it("user names separated spaces should not match", () => { 28 | expect(ATCODER_RIVALS_REGEXP.exec("user1 user2")).toBeFalsy(); 29 | }); 30 | 31 | it("user names including invalid char should not match", () => { 32 | expect(ATCODER_RIVALS_REGEXP.exec("user^, user|")).toBeFalsy(); 33 | }); 34 | }); 35 | 36 | it("extract rival params", () => { 37 | expect(extractRivalsParam(" user1 , USER2, user_3 ")).toMatchObject([ 38 | "user1", 39 | "USER2", 40 | "user_3", 41 | ]); 42 | }); 43 | 44 | it("should normalize user id", () => { 45 | expect(normalizeUserId(" userid")).toBe("userid"); 46 | expect(normalizeUserId("userid ")).toBe("userid"); 47 | expect(normalizeUserId(" userid ")).toBe("userid"); 48 | expect(normalizeUserId("userid")).toBe("userid"); 49 | expect(normalizeUserId("user id")).toBe(""); 50 | }); 51 | -------------------------------------------------------------------------------- /atcoder-problems-frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react", 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /doc/faq_ja.md: -------------------------------------------------------------------------------- 1 | # よくある質問 2 | 3 | ## ページが真っ白になる / 色がつかない 4 | 5 | ブラウザを最新版に更新してください。自分のブラウザが最新版だと思っていても最新版でないこともあります。本当に最新版なのに動かない時はバグなので教えてください。 6 | 7 | 各ブラウザのインストールはこちらから行えます。 8 | - [Google Chrome](https://www.google.com/intl/ja/chrome/) 9 | - [Firefox](https://www.mozilla.org/ja/firefox/new/) 10 | - [Microsoft Edge](https://www.microsoft.com/ja-jp/edge) 11 | 12 | ## AtCoder に提出したのに反映されません 13 | 14 | コンテスト終了直後はコンテスト中の提出をクロールしているので、反映が少し遅れるかもしれません。 15 | 普段は 5 分前後で反映されます。 16 | もし反映されなければシステムに不具合が発生しているかもしれないので、お知らせください。 17 | 18 | ## AtCoder 上でユーザー名を変更したため全て消えました 19 | 20 | AtCoder Problems は AtCoder の提出を常に再クロールし、更新されていればそれを AtCoder Problems にも反映しています。 21 | AtCoder の成長により提出数が爆発的に増えているので、全てを再クロールするのに数日〜数週間かかります。 22 | 気長にお待ちください。 23 | 24 | ## コンテスト中の提出は反映されますか 25 | 26 | 反映されます。 27 | コンテスト終了後に、新しい終了済みコンテストの検出、コンテスト中に提出された全提出のクロール、整合性のチェック等を経てデータベースに格納されます。 28 | 一連の工程が終わるまで数時間かかる場合もありますので、気長にお待ちください。 29 | 30 | ## 難易度を表す ◒ のような記号の読み方が分からない 31 | 32 | このサービスを作っている人たちが大学生の時に流行っていた TopCoder というサービスのオマージュです。 33 | ○ の中が満ちているほど難易度が高く推定されている問題です。 34 | マウスオーバーしたりクリックしたりすると、具体的な値を確認することができます。 35 | 36 | ## 難易度の値の意味がわからない 37 | 38 | 50%の確率で解けるレートの値です。 39 | コンテスト結果を使って統計的に推定しています。 40 | 大雑把に言うと、X 色の問題は「X 色の人の半分が解ける問題」という意味で「X 色の人が解ける問題」という意味ではありません。 41 | 42 | 参考: [AtCoder Problems の難易度推定について](http://pepsin-amylase.hatenablog.com/entry/atcoder-problems-difficulty) 43 | 44 | ## Current Streak / Longest Streak とは何ですか? 45 | 46 | Current Streak は今まで解いていない問題を新たに解き続けた日数です。Longest Streak は Current Streak の最大値です。 47 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | services: 4 | postgresql: 5 | image: postgres:12.3 6 | volumes: 7 | - ./config:/docker-entrypoint-initdb.d 8 | environment: 9 | POSTGRES_USER: db_user 10 | POSTGRES_PASSWORD: db_pass 11 | POSTGRES_DB: test_db 12 | POSTGRES_INITDB_ARGS: "--encoding=UTF8" 13 | ports: 14 | - "15432:5432" 15 | 16 | backend-development: 17 | build: 18 | context: ./atcoder-problems-backend 19 | dockerfile: Dockerfile.dev 20 | environment: 21 | SQL_URL: postgres://db_user:db_pass@postgresql:5432/test_db 22 | DATABASE_URL: postgres://db_user:db_pass@postgresql:5432/test_db 23 | RUST_LOG: info 24 | ports: 25 | - "8080:8080" 26 | depends_on: 27 | - postgresql 28 | volumes: 29 | - ./:/app 30 | - cargo-cache:/usr/local/cargo/registry 31 | - target-cache:/app/atcoder-problems-backend/target 32 | working_dir: /app/atcoder-problems-backend 33 | command: /bin/sh -c "cargo watch -s 'cargo run --bin run_server'" 34 | 35 | frontend-development: 36 | image: node:16 37 | ports: 38 | - "3000:3000" 39 | volumes: 40 | - ./:/app 41 | - node_modules:/app/atcoder-problems-frontend/node_modules 42 | working_dir: /app/atcoder-problems-frontend 43 | command: /bin/sh -c "yarn && yarn start" 44 | 45 | volumes: 46 | cargo-cache: null 47 | target-cache: null 48 | node_modules: null 49 | -------------------------------------------------------------------------------- /guide/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /guide/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenkoooo/AtCoderProblems/ff203e2dc12fece1e5c85dfcbd92db7312313634/guide/README.md -------------------------------------------------------------------------------- /guide/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = [] 3 | language = "ja" 4 | multilingual = false 5 | src = "src" 6 | -------------------------------------------------------------------------------- /guide/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | 4 | [はじめに](./index.md) 5 | 6 | # Japanese 7 | - [問題を探す](./ja/find_problems.md) 8 | - [進捗確認](./ja/progress.md) 9 | - [レコメンド](./ja/recommend.md) 10 | - [バーチャルコンテスト](./ja/virtual_contest.md) 11 | - [トレーニングモード](./ja/training.md) 12 | - [その他](./ja/misc.md) 13 | - [問題リスト作成](./ja/problem_list.md) 14 | - [進捗リセット](./ja/reset_progress.md) 15 | - [開発者向け・Contribution](./ja/for_developer.md) 16 | 17 | # English 18 | - [Find Problems](./en/find_problems.md) 19 | - [Progress](./en/progress.md) 20 | - [Recommend](./en/recommend.md) 21 | - [Virtual Contests](./en/virtual_contest.md) 22 | - [Others](./en/misc.md) 23 | - [My Lists](./en/problem_list.md) 24 | - [Reset Progress](./en/reset_progress.md) 25 | - [For Developers, Contribution](./en/for_developer.md) -------------------------------------------------------------------------------- /guide/src/en/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenkoooo/AtCoderProblems/ff203e2dc12fece1e5c85dfcbd92db7312313634/guide/src/en/README.md -------------------------------------------------------------------------------- /guide/src/en/find_problems.md: -------------------------------------------------------------------------------- 1 | # Finding Problems 2 | You can search for problems based on the following criteria: 3 | 1. Filter by AC/non-AC 4 | 1. Filter by AtCoder official score 5 | 1. Filter by difficulty calculated independently 6 | 1. Search by problem title, contest name, FA user, fastest user, etc. 7 | 8 | ## How to Search for Problems 9 | 1. Enter your AtCoder ID in the User ID field at the top left of the page, and select `List` at the top of the page. 10 | 1. By selecting the items under Problem List, you can filter problems based on AC/non-AC, AtCoder official score, and difficulty. 11 | 1. If you know the problem's name or want to search based on Fastest (shortest execution time), Shortest (shortest code length), or First (fastest submission time) users, you can enter the problem name or user name into the `Search` field on the right. 12 | -------------------------------------------------------------------------------- /guide/src/en/for_developer.md: -------------------------------------------------------------------------------- 1 | # For Developers / Contribution 2 | This page provides an overview of the contents of AtCoder Problems and details about contributions. 3 | # Components of AtCoder Problems 4 | An explanation of the components within AtCoder Problems. 5 | ## Crawler (Rust) 6 | - Submission Crawler 7 | - Contest and Problem Crawler 8 | ## API Server (Rust) 9 | ## Frontend (TypeScript) 10 | ## Aggregation Batch (Rust) 11 | - Differential Aggregation 12 | - Total Submission Aggregation 13 | ## Difficulty Estimation (Python) 14 | - [Algorithm](https://pepsin-amylase.hatenablog.com/entry/atcoder-problems-difficulty) 15 | 16 | # Contribution 17 | ## Want to add or fix features 18 | - [GitHub](https://github.com/kenkoooo/AtCoderProblems) 19 | 20 | ## Want to make a request or report a bug 21 | - [GitHub issues](https://github.com/kenkoooo/AtCoderProblems/issues) 22 | 23 | ## Want to contribute to server costs 24 | - [GitHub Sponsors](https://github.com/sponsors/kenkoooo) 25 | -------------------------------------------------------------------------------- /guide/src/en/index.md: -------------------------------------------------------------------------------- 1 | # AtCoder Problems User Guide (Japanese Version) 2 | 3 | This is the user guide for AtCoder Problems. 4 | This guide explains how to use the various features of AtCoder Problems. 5 | 6 | You can navigate to the desired page by using the sidebar on the left (menu icon at the top left) or the arrow buttons at the bottom of the page. 7 | -------------------------------------------------------------------------------- /guide/src/en/misc.md: -------------------------------------------------------------------------------- 1 | # Others 2 | ## Glossary 3 | - Difficulty 4 | - This value indicates that a person with the current internal rating (unadjusted rating) would have a 50% chance of solving the problem in a contest. 5 | - There are problems with a test tube emoji (🧪) next to the estimated difficulty. This indicates that the difficulty was estimated using somewhat forceful methods for problems before the official rating system was introduced. 6 | - A problem with a Difficulty of X means "half of the people with a rating of X can solve this problem," not "everyone with a rating of X can solve this problem." 7 | 8 | - Streak 9 | - The number of consecutive days you have solved new problems that you haven't solved before. 10 | - Longest Streak is calculated based on JST, while Current Streak is calculated based on Local Time. 11 | - Solving a problem that you have solved before will not increase the Streak count. 12 | 13 | ## References 14 | The following web pages were referenced in creating this user guide. 15 | 1. https://ntk-ta01.hatenablog.com/entry/2020/04/15/001405 16 | 1. https://trap.jp/post/992/ 17 | 1. https://pepsin-amylase.hatenablog.com/entry/atcoder-problems-difficulty 18 | -------------------------------------------------------------------------------- /guide/src/en/problem_list.md: -------------------------------------------------------------------------------- 1 | # Creating a Problem List 2 | You can create your own problem list for review and other purposes. 3 | ## How to Use 4 | 1. Log in from the Login button at the top right and enter your AtCoder ID to save it. 5 | 2. After logging in, the My Lists tab will appear in the menu. Click Create New List to enter the edit screen. 6 | 3. Click Add, and a prompt saying Search here to add problems will appear. Enter the title of the problem you want to add to the list and select the problem. 7 | 4. To delete a problem, click Remove. 8 | -------------------------------------------------------------------------------- /guide/src/en/progress.md: -------------------------------------------------------------------------------- 1 | # Progress Tracking 2 | You can check from the `User` tab. 3 | ## Achievement 4 | You can view numbers such as AC count (number of problems solved correctly), Rated Point Sum (total score of solved rated problems), and Current Streak (number of consecutive days solving new problems). 5 | 6 | ## AtCoder Pie Charts 7 | You can view pie charts showing the proportions of AC, Non-AC (submitted but not solved), and NoSub (not submitted) problems out of the total number of problems. 8 | 9 | ## Difficulty Pies 10 | You can view pie charts showing the proportions of AC, Non-AC (submitted but not solved), and NoSub (not submitted) problems for each difficulty level (grey, brown, green, etc.). 11 | 12 | ## Category Pies 13 | You can view pie charts showing the proportions of AC, Non-AC (submitted but not solved), and NoSub (not submitted) problems for each contest category. 14 | 15 | ## Progress Charts 16 | ### Daily Effort 17 | You can view the number of ACs per day. 18 | 19 | ### Climbing 20 | You can view the cumulative number of ACs, color coded by difficulty. 21 | 22 | ### Heatmap 23 | You can view the number of ACs per day in the same format as GitHub contributions. 24 | It is divided into All Submissions, All AC, Unique AC (problems solved for the first time), and Max Difficulty (the highest difficulty of problems solved that day). 25 | 26 | ## Submissions 27 | You can view the list of your submitted codes, their correctness status, and the languages used. 28 | 29 | ## Languages 30 | You can view the number of ACs, Longest Streak, Current Streak, and Last AC (date of the last AC) for each submission language. 31 | 32 | ## Trophy 33 | You can earn trophies by achieving specific milestones. The names of the trophies are a surprise until you achieve them. 34 | 35 | ## All 36 | You can view all items on one page. 37 | -------------------------------------------------------------------------------- /guide/src/en/recommend.md: -------------------------------------------------------------------------------- 1 | # Recommendation 2 | This feature automatically displays recommended problems based on your current rating. 3 | You can check it from the Recommendation tab on the User page. 4 | You can choose the difficulty of the problems from Easy, Moderate, or Difficult. 5 | 6 | Additionally, you can exclude problems that you have solved once or recently from the recommendations. 7 | 8 | ## Detailed Explanation 9 | - Easy problems are those with an 80% chance of being solved, Moderate problems with a 50% chance, and Difficult problems with a 20% chance. 10 | - Exclude ~~~ allows you to choose the type of problems to exclude from the recommendations. 11 | - If you select Don't exclude solved problems, all problems will be included in the recommendations. 12 | - The number on the right allows you to select the number of recommendations to display on the screen. 13 | - By selecting the checkbox next to the problem title, you can create a virtual contest with the selected problems. 14 | -------------------------------------------------------------------------------- /guide/src/en/reset_progress.md: -------------------------------------------------------------------------------- 1 | # Reset Progress 2 | You can set already AC problems to be treated as if they have not been AC yet. 3 | ## How to Use 4 | 1. Log in with your GitHub account from the Login button at the top right, then enter and save your AtCoder ID. 5 | 2. After logging in, the Reset Progress tab will appear in the menu. Specify the problem, and your progress will be reset. 6 | -------------------------------------------------------------------------------- /guide/src/en/training.md: -------------------------------------------------------------------------------- 1 | # Training Mode 2 | There are 300 problems available for beginners. 3 | You can access it from `Training` at the top left. Click `Challenge` to display the problem. -------------------------------------------------------------------------------- /guide/src/en/virtual_contest.md: -------------------------------------------------------------------------------- 1 | # Virtual Contest 2 | A virtual contest is a simulated contest where you can choose the problems you want to solve and set the time limit freely. 3 | 4 | ## Preparations for Participation 5 | First, log in with your GitHub account from the Login button at the top right. (If you do not have a GitHub account, please create one.) 6 | Then, enter and save your AtCoder ID. 7 | 8 | ## Creating a Virtual Contest 9 | 1. Click [Virtual Contests](https://kenkoooo.com/atcoder/#/contest/recent) at the top right to open the virtual contest page. 10 | 1. Click `Create New Contest` to go to the contest creation page. 11 | 1. Follow the instructions on the page to specify the date, time, and problems, and finally click `Create Contest` to complete the contest creation. 12 | - Tips for Creating a Virtual Contest 13 | - Bacha Gacha 14 | - This feature randomly selects problems when you specify a range of difficulty. 15 | - If you enter the AtCoder IDs of expected participants in the Expected Participants field and specify Exclude ~~~, problems that participants have already solved will be excluded. This is recommended to ensure fairness. 16 | - Mode 17 | - There are three types: Normal, Lockout, and Training. 18 | - Normal is the standard mode. Participants are ranked based on their total score and penalties. 19 | - In Lockout mode, only the first person to solve each problem gets the points. 20 | - In Training mode, the scoreboard display is simplified. This mode is recommended when there are many problems. Participants are ranked based on the total number of problems solved and the time of the last correct submission. 21 | 22 | ## Participating in a Virtual Contest 23 | 1. Click [Virtual Contests](https://kenkoooo.com/atcoder/#/contest/recent) at the top right to open the virtual contest page. 24 | Select the contest you want to participate in from Running Contests or Upcoming Contests. Click `Join` to complete the registration. 25 | 1. The problems will be displayed when the contest starts. 26 | 1. After getting AC on AtCoder, the standings on the virtual contest will be updated a few minutes later. 27 | -------------------------------------------------------------------------------- /guide/src/index.md: -------------------------------------------------------------------------------- 1 | # AtCoder Problems User Guide 2 | -------------------------------------------------------------------------------- /guide/src/ja/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenkoooo/AtCoderProblems/ff203e2dc12fece1e5c85dfcbd92db7312313634/guide/src/ja/README.md -------------------------------------------------------------------------------- /guide/src/ja/find_problems.md: -------------------------------------------------------------------------------- 1 | # 問題を探す 2 | 問題は以下の基準で検索することができます。 3 | 1. AC/非ACで絞り込み 4 | 1. AtCoder公式の点数で絞り込み 5 | 1. 独自に算出したDifficultyで絞り込み 6 | 1. 問題タイトル・コンテスト名・FAユーザー・最短ユーザー・最速ユーザー・etcで検索 7 | 8 | ## 問題の探し方 9 | 1. ページ左上のUser IDに自分のAtCoder IDを入力し、ページ上部の`List`を選択します。 10 | 1. Problem Listの下にある項目をそれぞれ選ぶことで、AC/非AC、AtCoder公式の点数、Difficultyを基準に問題を絞り込むことができます。 11 | 1. 問題の名前が分かる場合や、Fastest(実行時間が最短) 12 | Shortest(コード長が最短)、First(提出時間が最も速い)ユーザーを基準に検索する場合は、右側にある`Search`に問題名やユーザー名を入力することで検索できます。 13 | 14 | -------------------------------------------------------------------------------- /guide/src/ja/for_developer.md: -------------------------------------------------------------------------------- 1 | # 開発者向け・Contribution 2 | AtCoder Problemsの中身についての解説と、Contribution についてまとめたページです。 3 | # AtCoder Problems のコンポーネント 4 | AtCoder Problemsの中身について解説します。 5 | ## クローラー(Rust) 6 | - 提出クローラー 7 | - コンテスト・問題クローラー 8 | ## APIサーバー(Rust) 9 | ## フロントエンド(TypeScript) 10 | ## 集計バッチ(Rust) 11 | - 差分集計 12 | - 全提出集計 13 | ## 難易度推定(Python) 14 | - [アルゴリズム](https://pepsin-amylase.hatenablog.com/entry/atcoder-problems-difficulty) 15 | 16 | # Contribution 17 | ## 機能を追加したい・機能を修正したい 18 | - [GitHub](https://github.com/kenkoooo/AtCoderProblems) 19 | 20 | ## 要望を出したい・バグを報告したい 21 | - [GitHub issues](https://github.com/kenkoooo/AtCoderProblems/issues) 22 | 23 | ## サーバー代をカンパしたい 24 | - [GitHub Sponsors](https://github.com/sponsors/kenkoooo) 25 | -------------------------------------------------------------------------------- /guide/src/ja/index.md: -------------------------------------------------------------------------------- 1 | # AtCoder Problems ユーザーガイド(日本語版) 2 | 3 | AtCoder Problemsのユーザーガイドです。 4 | このガイドでは、AtCoder Problemsの持つ様々な機能の使い方について解説します。 5 | 6 | 左側のサイドバー(左上のメニューアイコン)またはページ下部にある矢印ボタンを使用することで、読みたいページに移動できます。 -------------------------------------------------------------------------------- /guide/src/ja/misc.md: -------------------------------------------------------------------------------- 1 | # その他 2 | ## 用語解説 3 | - Difficulty 4 | - 現在の内部レーティング(補正されていないレーティング)がこの値の人がコンテストでその問題を見たら50%の確率で解けると考えられる値です。 5 | - 推定難易度の横に試験管の絵文字(🧪)がついている問題があります。これは公式のレーティングシステムが導入される以前の問題に対して、やや強引な手法で難易度を推定したものです。 6 | - DifficultyがXの問題は「レーティングがXの人の半分が解ける問題」という意味で「レーティングXの人全員が解ける問題」という意味ではありません。 7 | 8 | - Streak 9 | - 今まで解いていない問題を新たに解き続けた日数です。 10 | - Longest StreakはJST、Current StreakはLocal Timeに基づいて集計されます。 11 | - 今までに解いたことのある問題を再び解いても、Streakのカウントが増えることはありません。 12 | 13 | ## 参考資料 14 | ユーザーガイドを作るにあたって以下のwebページを参考にさせていただきました。 15 | 1. https://ntk-ta01.hatenablog.com/entry/2020/04/15/001405 16 | 1. https://trap.jp/post/992/ 17 | 1. https://pepsin-amylase.hatenablog.com/entry/atcoder-problems-difficulty 18 | -------------------------------------------------------------------------------- /guide/src/ja/problem_list.md: -------------------------------------------------------------------------------- 1 | # 問題リスト作成 2 | 自分だけの問題リストを作って、復習等に使うことができます。 3 | ## 使い方 4 | 1. 右上の Login からログイン後に AtCoder ID を入力して保存します。 5 | 2. ログイン後のメニューに My Lists タブが出てくるので、Create New Listを押して編集画面に入ります。 6 | 3. Addを押すと、Search here to add problems と出てくるので、リストに入れたい問題のタイトルを入力し、問題を選択します。 7 | 4. 削除したい場合はRemoveを押すと削除されます。 -------------------------------------------------------------------------------- /guide/src/ja/progress.md: -------------------------------------------------------------------------------- 1 | # 進捗確認 2 | `User`タブから確認することができます。 3 | ## Achievement 4 | AC数(今までに正解した問題数)、Rated Point Sum(正解した問題のうちRatedの問題の合計点数)、Current Streak(今まで解いていない問題を新たに解き続けた日数)などを数値で確認できます。 5 | ## AtCoder Pie Charts 6 | 全体の出題数のうち、AC、Non-AC(提出したがACしていないもの)、NoSub(提出していないもの)の割合を円グラフで確認できます。 7 | ## Difficulty Pies 8 | 各Difficultyごと(灰色、茶色、緑色、......)にAC、Non-AC(提出したがACしていないもの)、NoSub(提出していないもの)の割合を円グラフで確認できます。 9 | ## Category Pies 10 | コンテストの種別ごとにAC、Non-AC(提出したがACしていないもの)、NoSub(提出していないもの)の割合を円グラフで確認できます。 11 | ## Progress Charts 12 | ### Daily Effort 13 | 一日ごとのAC数を確認できます。 14 | ### Climbing 15 | 今までの累計AC数をDifficultyで色分けされた状態で確認できます。 16 | ### Heatmap 17 | GitHubのコントリビューションと同じ形式で一日ごとのAC数を確認できます。 18 | All Submissions、 All AC、 Unique AC(初めてACした問題)、Max Difficulty(その日ACした問題のDifficultyの最大値)に分かれています。 19 | ## Submissions 20 | 今までに提出したコードの正解状況や言語の一覧を確認できます。 21 | ## Languages 22 | 提出言語ごとにAC数、Longest Streak、Current Streak、Last AC(最後にACした日付)を確認できます。 23 | ## Trophy 24 | 特定の実績を達成するとTrophyをもらえます。どのような名前のTrophyをもらえるかは、達成してからのお楽しみです。 25 | 26 | ## All 27 | 全ての項目を1つのページで確認できます。 28 | -------------------------------------------------------------------------------- /guide/src/ja/recommend.md: -------------------------------------------------------------------------------- 1 | # レコメンド 2 | 自身の現在のレーティングを基に、自動的にお勧めの問題を表示する機能です。 3 | UserページのRecommendationタブから確認することができます。 4 | Easy/Moderate/Difficultの3つから問題の難易度を選ぶことができます。 5 | 6 | また、過去に一度でも正解した問題や、最近正解した問題を推薦から除外することもできます。 7 | 8 | ## 詳細な説明 9 | - Easyは80%、Moderateは50%、Difficultは20%の確率で解ける難易度の問題となっています。 10 | - Exclude ~~~は、レコメンドから除外する問題の種類を選ぶものです。 11 | - Don't exclude solved problemsを選んだ場合、全ての問題がレコメンド対象となります。 12 | - 右側にある数字は、画面上に表示するレコメンドの数を選択するものです。 13 | - 問題タイトルの左にあるチェックボックスで問題を選択すると、選択した問題でバーチャルコンテストを作ることができます。 -------------------------------------------------------------------------------- /guide/src/ja/reset_progress.md: -------------------------------------------------------------------------------- 1 | # 進捗リセット 2 | 既にACした問題を、まだACしていない問題として扱うように設定できます。 3 | ## 使い方 4 | 1. 右上の Login からGithubアカウントでログインした後に、 AtCoder ID を入力して保存します。 5 | 2. ログイン後のメニューに Reset Progress タブが出てくるので、問題を指定すると、進捗がリセットされます。 -------------------------------------------------------------------------------- /guide/src/ja/training.md: -------------------------------------------------------------------------------- 1 | # トレーニングモード 2 | 初心者向けの問題が300問用意されています。 3 | 左上の`Training`から開くことができます。`Challenge`をクリックすると問題が表示されます。 -------------------------------------------------------------------------------- /guide/src/ja/virtual_contest.md: -------------------------------------------------------------------------------- 1 | # バーチャルコンテスト 2 | バーチャルコンテストは、解きたい問題と制限時間を自由に選んで参加できる仮想のコンテストです。 3 | 4 | ## 参加のための下準備 5 | まず、右上の Login からGitHubアカウントでログインします。(GitHubアカウントを持っていない場合は、作成してください。) 6 | その後、 AtCoder ID を入力して保存します。 7 | 8 | 9 | ## バーチャルコンテストを作る 10 | 1. 右上の[Virtual Contests](https://kenkoooo.com/atcoder/#/contest/recent)をクリックすると、バーチャルコンテストのページが開きます。 11 | 1. `Create New Contest`をクリックすると、コンテスト作成ページに飛びます。 12 | 1. ページ内の表記に従って日時や問題を指定し、最後に`Create Contest`をクリックすると、コンテスト作成は完了です。 13 | - バーチャルコンテスト作成のヒント 14 | - Bacha Gacha 15 | - Difficultyの範囲を指定すると問題をランダムに選んでくれる機能です。 16 | - Expected Participantsに参加者のAtCoder IDを入力してExclude ~~~を指定すると、参加者が解いたことのある問題を除外できます。公平性を保ちたい場合にお勧めです。 17 | - Mode 18 | - Normal, Lockout, Trainingの3種類があります。 19 | - Normalは通常モードです。参加者は、合計得点とペナルティによって順位付けされます。 20 | - Lockoutでは、各問題で最初に正解した人のみが得点を得られます。 21 | - Trainingでは、順位表の表示が簡略化されます。問題数が多い場合にお勧めのモードです。参加者は、正解した問題の総数、次に最後に正解した提出の時間によって順位付けされます。 22 | 23 | ## バーチャルコンテストに参加する 24 | 25 | 1. 右上の[Virtual Contests](https://kenkoooo.com/atcoder/#/contest/recent)をクリックすると、バーチャルコンテストのページが開きます。 26 | Running ContestsやUpcoming Contestsから、参加したいコンテストを選びます。`Join`をクリックすると、参加登録は完了です。 27 | 1. 時間になると問題が表示されます。 28 | 1. AtCoder上でACとなってから数分経つと、バーチャルコンテストの順位表でも正解状況が更新されます。 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /lambda-functions/official-api-proxy/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -xue 4 | 5 | TEMP_DIR=__build_$(date +%s) 6 | mkdir ${TEMP_DIR} 7 | pip install requests -t ${TEMP_DIR} 8 | cp function.py ${TEMP_DIR}/ 9 | cd ${TEMP_DIR} && zip -r ../function.zip * 10 | cd ../ 11 | rm -r ${TEMP_DIR} -------------------------------------------------------------------------------- /lambda-functions/official-api-proxy/function.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | import json 5 | 6 | ATCODER_API_PATTERN = re.compile(r"^users/.+/history/json$") 7 | 8 | 9 | def handler(event, context): 10 | print(json.dumps(event)) 11 | path = event["pathParameters"]["proxy"] 12 | if not ATCODER_API_PATTERN.match(path): 13 | return "The path requested is not supported." 14 | resp = requests.get(f"https://atcoder.jp/{path}").json() 15 | return { 16 | "isBase64Encoded": False, 17 | "statusCode": 200, 18 | "headers": { 19 | "Access-Control-Allow-Origin": "*" 20 | }, 21 | "body": json.dumps(resp) 22 | } 23 | -------------------------------------------------------------------------------- /lambda-functions/time-estimator/.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | -------------------------------------------------------------------------------- /lambda-functions/time-estimator/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -xue 4 | 5 | TEMP_DIR=__build_$(date +%s) 6 | mkdir ${TEMP_DIR} 7 | pip install requests -t ${TEMP_DIR} 8 | cp function.py rating.py ${TEMP_DIR}/ 9 | cd ${TEMP_DIR} && zip -r ../function.zip * 10 | cd ../ 11 | rm -r ${TEMP_DIR} 12 | -------------------------------------------------------------------------------- /lambda-functions/time-estimator/delete.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List 3 | import requests 4 | 5 | 6 | def main(problem_ids: List[str]): 7 | problem_models = requests.get("https://kenkoooo.com/atcoder/resources/problem-models.json").json() 8 | print(f"removing {len(problem_ids)} models") 9 | for problem_id in problem_ids: 10 | model = problem_models.pop(problem_id, None) 11 | if model is None: 12 | print(f"{problem_id} not found.") 13 | else: 14 | print(f"{problem_id} is removed") 15 | with open("problem-models.json", "w") as f: 16 | json.dump(problem_models, f) 17 | 18 | 19 | if __name__ == '__main__': 20 | from argparse import ArgumentParser 21 | parser = ArgumentParser() 22 | parser.add_argument("problem_ids", nargs="*") 23 | args = parser.parse_args() 24 | main(**vars(args)) -------------------------------------------------------------------------------- /lambda-functions/time-estimator/local_generate.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from shutil import move 4 | 5 | from function import run, login 6 | 7 | if __name__ == '__main__': 8 | if not Path("problem-models-old.json").exists() and Path("problem-models.json").exists(): 9 | move("problem-models.json", "problem-models-old.json") 10 | session = login(None, None) # set your credential before use. Do not commit it! 11 | results = run(None, True, session) 12 | with open("problem-models.json", "w") as f: 13 | json.dump(results, f) 14 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenkoooo/AtCoderProblems/ff203e2dc12fece1e5c85dfcbd92db7312313634/screenshot.png --------------------------------------------------------------------------------