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 && ( + + )} +
+ ); +}