From 011aca4ff491e6eb412326af8fb539d581fb3bbe Mon Sep 17 00:00:00 2001 From: Ryan Pope Date: Sat, 31 Oct 2026 11:10:36 -0500 Subject: [PATCH] feat(web): list solved problems on the Progress page The stats endpoint returns every solved problem with its first accepted time and the languages it was accepted in. The Progress page shows them, most recent first, and the solved counts link to the problem list filtered to solved problems. --- CHANGELOG.md | 1 + backend/localcode/services/models.py | 10 ++++ backend/localcode/services/progress.py | 19 +++++-- backend/tests/test_services.py | 21 ++++++++ web/openapi.json | 69 +++++++++++++++++++++++++- web/src/generated/api.ts | 20 ++++++++ web/src/pages/Progress.module.css | 21 ++++++++ web/src/pages/Progress.tsx | 59 ++++++++++++++++++++-- 8 files changed, 211 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f44ee9f..c8ea533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes are documented here. The format follows [Keep a Changelog](h - Problem list, company pages, workspace with Run/Submit and custom cases, submission history, progress dashboard and export. - One-command setup with Docker Compose; content imported from open datasets at first run, with mirror fallbacks. - Repo-kept content in `content/`: new problems and extra solutions, tests or corrections for existing ones, checked in CI by `make validate-content` and marked "local" in the app. +- The Progress page lists every solved problem, with when it was first accepted and in which languages; the solved counts link to the filtered problem list. - **Refresh content** never removes problems, solutions or test cases that an earlier import had. ### Security diff --git a/backend/localcode/services/models.py b/backend/localcode/services/models.py index 449c7ed..8f9e239 100644 --- a/backend/localcode/services/models.py +++ b/backend/localcode/services/models.py @@ -184,12 +184,22 @@ class CompanyProgress(TypedDict): total: int +class SolvedProblem(TypedDict): + slug: str + id: int | None + title: str + difficulty: Difficulty + first_accepted_at: str + langs: list[Lang] # languages with an accepted submission + + class Stats(TypedDict): since: str | None totals: DifficultyCounts solved_by_week: list[WeekCount] by_topic: list[TopicProgress] by_company: list[CompanyProgress] + solved: list[SolvedProblem] # newest first accept first class ImportState(TypedDict): diff --git a/backend/localcode/services/progress.py b/backend/localcode/services/progress.py index 641171c..fd55003 100644 --- a/backend/localcode/services/progress.py +++ b/backend/localcode/services/progress.py @@ -68,11 +68,13 @@ def _week_start(d: date) -> date: def stats() -> Stats: - """Totals by difficulty, first-accepted problems per week, and coverage by topic and company.""" + """Totals by difficulty, first-accepted problems per week, coverage by topic and company, and every + solved problem.""" with content_session() as con: firsts = con.execute( - "select s.slug, min(s.created_at) as at, p.difficulty, p.tags from u.submissions s " - "join problems p on p.slug = s.slug where s.verdict = 'Accepted' group by s.slug" + "select s.slug, min(s.created_at) as at, p.frontend_id, p.title, p.difficulty, p.tags, " + "json_group_array(distinct s.lang) as langs from u.submissions s " + "join problems p on p.slug = s.slug where s.verdict = 'Accepted' group by s.slug order by at desc" ).fetchall() difficulty_totals = dict(con.execute("select difficulty, count(*) from problems group by 1").fetchall()) topic_totals = Counter( @@ -114,6 +116,17 @@ def stats() -> Stats: {"name": n, "solved": topic_solved[n], "total": t} for n, t in topic_totals.most_common(STATS_TOPICS) ], "by_company": by_company, + "solved": [ + { + "slug": r["slug"], + "id": r["frontend_id"], + "title": r["title"], + "difficulty": r["difficulty"], + "first_accepted_at": r["at"], + "langs": sorted(json.loads(r["langs"])), + } + for r in firsts + ], } diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index f0623c1..db91ab4 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -1,6 +1,7 @@ import pytest from localcode import db +from localcode.db import user_session from localcode.services import companies, judge, problems, progress from localcode.services.errors import ServiceError @@ -162,6 +163,26 @@ def test_stats_counts_first_accept(data_dir, runner): assert sum(w["easy"] for w in s["solved_by_week"]) == 1 +def test_stats_lists_solved_problems_with_first_accept_and_languages(data_dir, runner): + runner.user = ok([[1, 2], [0, 1], [0, 2], [0, 1]]) + judge.submit(runner, "pair-sum", "python", "code") + judge.submit(runner, "pair-sum", "java", "code") + judge.submit(runner, "pair-sum", "python", "code") + runner.user = ok([[9, 9], [9, 9], [9, 9], [9, 9]]) + judge.submit(runner, "pair-sum", "cpp", "code") # wrong answers don't count + (solved,) = progress.stats()["solved"] + with user_session() as u: + first = u.execute("select min(created_at) from submissions where verdict = 'Accepted'").fetchone()[0] + assert solved["slug"] == "pair-sum" + assert solved["difficulty"] == "Easy" + assert solved["first_accepted_at"] == first + assert solved["langs"] == ["java", "python"] + + +def test_stats_solved_is_empty_before_any_accept(data_dir): + assert progress.stats()["solved"] == [] + + def test_unsolved_filter_includes_attempted(data_dir): progress.save_draft("pair-sum", "python", "x") slugs = [p["slug"] for p in problems.list_problems(status="unsolved")["items"]] diff --git a/web/openapi.json b/web/openapi.json index b7295c9..2169040 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -977,6 +977,65 @@ "title": "Solution", "type": "object" }, + "SolvedProblem": { + "properties": { + "difficulty": { + "enum": [ + "Easy", + "Medium", + "Hard" + ], + "title": "Difficulty", + "type": "string" + }, + "first_accepted_at": { + "title": "First Accepted At", + "type": "string" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "langs": { + "items": { + "enum": [ + "python", + "java", + "cpp", + "kotlin" + ], + "type": "string" + }, + "title": "Langs", + "type": "array" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "slug", + "id", + "title", + "difficulty", + "first_accepted_at", + "langs" + ], + "title": "SolvedProblem", + "type": "object" + }, "Stats": { "properties": { "by_company": { @@ -1004,6 +1063,13 @@ ], "title": "Since" }, + "solved": { + "items": { + "$ref": "#/components/schemas/SolvedProblem" + }, + "title": "Solved", + "type": "array" + }, "solved_by_week": { "items": { "$ref": "#/components/schemas/WeekCount" @@ -1020,7 +1086,8 @@ "totals", "solved_by_week", "by_topic", - "by_company" + "by_company", + "solved" ], "title": "Stats", "type": "object" diff --git a/web/src/generated/api.ts b/web/src/generated/api.ts index 045aee6..8d46edb 100644 --- a/web/src/generated/api.ts +++ b/web/src/generated/api.ts @@ -581,6 +581,24 @@ export interface components { /** Title */ title: string; }; + /** SolvedProblem */ + SolvedProblem: { + /** + * Difficulty + * @enum {string} + */ + difficulty: "Easy" | "Medium" | "Hard"; + /** First Accepted At */ + first_accepted_at: string; + /** Id */ + id: number | null; + /** Langs */ + langs: ("python" | "java" | "cpp" | "kotlin")[]; + /** Slug */ + slug: string; + /** Title */ + title: string; + }; /** Stats */ Stats: { /** By Company */ @@ -589,6 +607,8 @@ export interface components { by_topic: components["schemas"]["TopicProgress"][]; /** Since */ since: string | null; + /** Solved */ + solved: components["schemas"]["SolvedProblem"][]; /** Solved By Week */ solved_by_week: components["schemas"]["WeekCount"][]; totals: components["schemas"]["DifficultyCounts"]; diff --git a/web/src/pages/Progress.module.css b/web/src/pages/Progress.module.css index d514618..e2d9234 100644 --- a/web/src/pages/Progress.module.css +++ b/web/src/pages/Progress.module.css @@ -82,3 +82,24 @@ margin: 4px 0 0; padding-left: 18px; } +.solvedRow { + display: grid; + grid-template-columns: minmax(0, 1fr) 64px 160px 84px; + gap: 12px; + align-items: center; + font-size: 12.5px; +} +.statLink { + color: inherit; +} +.statLink:hover { + color: var(--fg); +} +@media (max-width: 640px) { + .solvedRow { + grid-template-columns: minmax(0, 1fr) 56px 76px; + } + .solvedRow > :global(.note) { + display: none; + } +} diff --git a/web/src/pages/Progress.tsx b/web/src/pages/Progress.tsx index 66b0873..cae7c8d 100644 --- a/web/src/pages/Progress.tsx +++ b/web/src/pages/Progress.tsx @@ -1,11 +1,12 @@ -import { refreshSummary } from "../lib/labels"; +import { problemLabel, refreshSummary } from "../lib/labels"; +import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "react-router-dom"; -import { api, type Stats } from "../api"; +import { api, LANG_LABEL, type Stats } from "../api"; import { CardHeader, Meter, PageHeader, Stat } from "../components/layout"; import QueryState from "../components/QueryState"; import { useAppStatus } from "../hooks/useAppStatus"; -import { CompanyBadge, Icon } from "../components/ui"; +import { CompanyBadge, DiffText, Icon } from "../components/ui"; import { ICONS } from "../lib/icons"; import styles from "./Progress.module.css"; @@ -15,6 +16,9 @@ const DIFFS = [ ["hard", "Hard", "var(--hard)"], ] as const; +/** Solved problems shown before "Show all". */ +const SOLVED_PREVIEW = 10; + export default function Progress() { const stats = useQuery({ queryKey: ["stats"], queryFn: api.stats }); return {(s) => }; @@ -47,13 +51,21 @@ function ProgressView({ stats: s }: { stats: Stats }) {
-

Solved

+

+ + Solved + +

{DIFFS.map(([k, label, color]) => (
-

{label}

+

+ + {label} + +

@@ -94,6 +106,8 @@ function ProgressView({ stats: s }: { stats: Stats }) {
+ +
@@ -165,3 +179,38 @@ function ProgressView({ stats: s }: { stats: Stats }) { ); } + +function SolvedList({ solved }: { solved: Stats["solved"] }) { + const [showAll, setShowAll] = useState(false); + const rows = showAll ? solved : solved.slice(0, SOLVED_PREVIEW); + return ( +
+ +
+ {solved.length > 0 && ( + + Open in Problems → + + )} + + {solved.length === 0 && Problems you get Accepted on will be listed here.} + {rows.map((p) => ( +
+ + {problemLabel(p)} + + + {p.langs.map((l) => LANG_LABEL[l]).join(", ")} + +
+ ))} + {solved.length > SOLVED_PREVIEW && ( + + )} +
+ ); +}