diff --git a/docs/superpowers/plans/2026-05-11-problem-yearly-ac-rate.md b/docs/superpowers/plans/2026-05-11-problem-yearly-ac-rate.md new file mode 100644 index 0000000..9db48a6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-problem-yearly-ac-rate.md @@ -0,0 +1,495 @@ +# Problem Yearly AC Rate — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `GET /api/problem/yearly_ac` endpoint returning per-year AC stats for a single problem, and display results as a line chart below the existing pie chart on the problem info page. + +**Architecture:** Backend adds `ProblemYearlyACRateAPI` to `problem/views/oj.py` using Django ORM `ExtractYear` + `Count(filter=...)` aggregation, cached in Redis. Frontend adds a `ProblemYearlyChart.vue` Line chart component and wires it into `ProblemInfo.vue`. + +**Tech Stack:** Django 6, PostgreSQL, Redis, `django.db.models.functions.ExtractYear`; Vue 3, TypeScript, vue-chartjs, chart.js + +--- + +### Task 1: Add CacheKey constant and write failing test + +**Files:** +- Modify: `OnlineJudge/utils/constants.py` +- Create: `OnlineJudge/problem/tests.py` + +- [ ] **Step 1: Add `problem_yearly_ac` to `CacheKey`** + +In `OnlineJudge/utils/constants.py`, add one line to `CacheKey`: + +```python +class CacheKey: + waiting_queue = "waiting_queue" + contest_rank_cache = "contest_rank_cache" + website_config = "website_config" + problem_authors = "problem_authors" + problem_tags = "problem_tags" + comment_stats = "comment_stats" + user_activity_rank = "user_activity_rank" + problem_yearly_ac = "problem_yearly_ac" # ← add this line +``` + +- [ ] **Step 2: Write failing tests** + +Create `OnlineJudge/problem/tests.py`: + +```python +from datetime import datetime, UTC + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase + +from problem.models import Problem, ProblemRuleType +from submission.models import JudgeStatus, Submission +from utils.constants import Difficulty + +User = get_user_model() + + +def make_user(): + return User.objects.create_user( + username="testuser", + email="test@example.com", + password="pass", + ) + + +def make_problem(user, _id="P001"): + return Problem.objects.create( + _id=_id, + title="Test Problem", + description="", + input_description="", + output_description="", + samples=[], + test_case_id="abc123", + test_case_score=[], + languages=["Python3"], + template={}, + created_by=user, + time_limit=1000, + memory_limit=256, + rule_type=ProblemRuleType.ACM, + difficulty=Difficulty.LOW, + ) + + +def make_submission(problem, result, year): + sub = Submission.objects.create( + problem=problem, + user_id=1, + username="testuser", + code="print(1)", + result=result, + language="Python3", + ) + # auto_now_add cannot be passed to create(); update afterward + Submission.objects.filter(id=sub.id).update( + create_time=datetime(year, 6, 1, tzinfo=UTC) + ) + return sub + + +class ProblemYearlyACRateAPITest(TestCase): + def setUp(self): + self.client = Client() + self.user = make_user() + self.problem = make_problem(self.user) + # 2023: 3 judged, 1 AC → ac_rate = 33.33 + make_submission(self.problem, JudgeStatus.ACCEPTED, 2023) + make_submission(self.problem, JudgeStatus.WRONG_ANSWER, 2023) + make_submission(self.problem, JudgeStatus.WRONG_ANSWER, 2023) + # 2024: 2 judged, 2 AC → ac_rate = 100.0 + make_submission(self.problem, JudgeStatus.ACCEPTED, 2024) + make_submission(self.problem, JudgeStatus.ACCEPTED, 2024) + + def test_missing_problem_id_returns_error(self): + res = self.client.get("/api/problem/yearly_ac") + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["error"], "error") + + def test_nonexistent_problem_returns_error(self): + res = self.client.get("/api/problem/yearly_ac?problem_id=NOPE") + self.assertEqual(res.json()["error"], "error") + + def test_returns_yearly_data_sorted_by_year(self): + res = self.client.get(f"/api/problem/yearly_ac?problem_id={self.problem._id}") + body = res.json() + self.assertIsNone(body["error"]) + rows = body["data"] + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["year"], 2023) + self.assertEqual(rows[0]["total"], 3) + self.assertEqual(rows[0]["accepted"], 1) + self.assertAlmostEqual(rows[0]["ac_rate"], 33.33, places=1) + self.assertEqual(rows[1]["year"], 2024) + self.assertEqual(rows[1]["total"], 2) + self.assertEqual(rows[1]["accepted"], 2) + self.assertAlmostEqual(rows[1]["ac_rate"], 100.0) + + def test_excludes_pending_and_judging(self): + make_submission(self.problem, JudgeStatus.PENDING, 2023) + make_submission(self.problem, JudgeStatus.JUDGING, 2023) + res = self.client.get(f"/api/problem/yearly_ac?problem_id={self.problem._id}") + rows = res.json()["data"] + # 2023 total must stay 3 (pending/judging excluded) + self.assertEqual(rows[0]["total"], 3) +``` + +- [ ] **Step 3: Run test to confirm it fails** + +```bash +cd OnlineJudge && python manage.py test problem.tests.ProblemYearlyACRateAPITest -v 2 +``` + +Expected: Failure with `404` or `ImportError` — endpoint does not exist yet. + +--- + +### Task 2: Implement view and register URL + +**Files:** +- Modify: `OnlineJudge/problem/views/oj.py` +- Modify: `OnlineJudge/problem/urls/oj.py` + +- [ ] **Step 1: Add `ExtractYear` import to `problem/views/oj.py`** + +At the top of `OnlineJudge/problem/views/oj.py`, the existing imports already include `from django.db.models import Count, Q`. Add one more: + +```python +from django.db.models.functions import ExtractYear +``` + +Also confirm `from utils.constants import CacheKey` is present (it is, via existing cache usage). + +- [ ] **Step 2: Add `ProblemYearlyACRateAPI` class at end of file** + +Append to `OnlineJudge/problem/views/oj.py`: + +```python +class ProblemYearlyACRateAPI(APIView): + def get(self, request): + problem_id = request.GET.get("problem_id") + if not problem_id: + return self.error("problem_id is required") + + cache_key = f"{CacheKey.problem_yearly_ac}:{problem_id}" + cached = cache.get(cache_key) + if cached is not None: + return self.success(cached) + + try: + problem = Problem.objects.get( + _id=problem_id, contest_id__isnull=True, visible=True + ) + except Problem.DoesNotExist: + return self.error("Problem does not exist") + + rows = ( + Submission.objects.filter( + problem_id=problem.id, + contest_id__isnull=True, + ) + .exclude(result__in=[JudgeStatus.PENDING, JudgeStatus.JUDGING]) + .annotate(year=ExtractYear("create_time")) + .values("year") + .annotate( + total=Count("id"), + accepted=Count("id", filter=Q(result=JudgeStatus.ACCEPTED)), + ) + .order_by("year") + ) + + data = [ + { + "year": row["year"], + "total": row["total"], + "accepted": row["accepted"], + "ac_rate": round(row["accepted"] / row["total"] * 100, 2) + if row["total"] > 0 + else 0.0, + } + for row in rows + ] + + cache.set(cache_key, data, 3600) + return self.success(data) +``` + +- [ ] **Step 3: Register URL in `problem/urls/oj.py`** + +Replace the full contents of `OnlineJudge/problem/urls/oj.py` with: + +```python +from django.urls import path + +from ..views.oj import ( + ContestProblemAPI, + PickOneAPI, + ProblemAPI, + ProblemAuthorAPI, + ProblemSolvedPeopleCount, + ProblemTagAPI, + ProblemYearlyACRateAPI, + SimilarProblemAPI, +) + +urlpatterns = [ + path("problem/tags", ProblemTagAPI.as_view()), + path("problem", ProblemAPI.as_view()), + path("problem/beat_count", ProblemSolvedPeopleCount.as_view()), + path("problem/similar", SimilarProblemAPI.as_view()), + path("problem/author", ProblemAuthorAPI.as_view()), + path("problem/yearly_ac", ProblemYearlyACRateAPI.as_view()), + path("pickone", PickOneAPI.as_view()), + path("contest/problem", ContestProblemAPI.as_view()), +] +``` + +- [ ] **Step 4: Run tests — expect all 4 to pass** + +```bash +cd OnlineJudge && python manage.py test problem.tests.ProblemYearlyACRateAPITest -v 2 +``` + +Expected output: +``` +test_excludes_pending_and_judging ... ok +test_missing_problem_id_returns_error ... ok +test_nonexistent_problem_returns_error ... ok +test_returns_yearly_data_sorted_by_year ... ok + +Ran 4 tests in ...s + +OK +``` + +- [ ] **Step 5: Commit** + +```bash +git -C OnlineJudge add utils/constants.py problem/views/oj.py problem/urls/oj.py problem/tests.py +git -C OnlineJudge commit -m "feat: add problem yearly AC rate API endpoint" +``` + +--- + +### Task 3: Frontend — add API function + +**Files:** +- Modify: `ojnext/src/oj/api.ts` + +- [ ] **Step 1: Add `YearlyACData` type and `getProblemYearlyAC` function** + +In `ojnext/src/oj/api.ts`, add after the `getSimilarProblems` function (near the `// ==================== 相似题目推荐 ====================` section): + +```typescript +export interface YearlyACData { + year: number + total: number + accepted: number + ac_rate: number +} + +export function getProblemYearlyAC(problemId: string) { + return http.get("problem/yearly_ac", { + params: { problem_id: problemId }, + }) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git -C ojnext add src/oj/api.ts +git -C ojnext commit -m "feat: add getProblemYearlyAC API function" +``` + +--- + +### Task 4: Frontend — create `ProblemYearlyChart.vue` + +**Files:** +- Create: `ojnext/src/oj/problem/components/ProblemYearlyChart.vue` + +- [ ] **Step 1: Create the component** + +Create `ojnext/src/oj/problem/components/ProblemYearlyChart.vue` with the following content: + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git -C ojnext add src/oj/problem/components/ProblemYearlyChart.vue +git -C ojnext commit -m "feat: add ProblemYearlyChart component" +``` + +--- + +### Task 5: Frontend — integrate chart into `ProblemInfo.vue` + +**Files:** +- Modify: `ojnext/src/oj/problem/components/ProblemInfo.vue` + +- [ ] **Step 1: Add imports** + +In the `