Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions backend/localcode/services/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
19 changes: 16 additions & 3 deletions backend/localcode/services/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
],
}


Expand Down
21 changes: 21 additions & 0 deletions backend/tests/test_services.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"]]
Expand Down
69 changes: 68 additions & 1 deletion web/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -1004,6 +1063,13 @@
],
"title": "Since"
},
"solved": {
"items": {
"$ref": "#/components/schemas/SolvedProblem"
},
"title": "Solved",
"type": "array"
},
"solved_by_week": {
"items": {
"$ref": "#/components/schemas/WeekCount"
Expand All @@ -1020,7 +1086,8 @@
"totals",
"solved_by_week",
"by_topic",
"by_company"
"by_company",
"solved"
],
"title": "Stats",
"type": "object"
Expand Down
20 changes: 20 additions & 0 deletions web/src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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"];
Expand Down
21 changes: 21 additions & 0 deletions web/src/pages/Progress.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
59 changes: 54 additions & 5 deletions web/src/pages/Progress.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 <QueryState query={stats}>{(s) => <ProgressView stats={s} />}</QueryState>;
Expand Down Expand Up @@ -47,13 +51,21 @@ function ProgressView({ stats: s }: { stats: Stats }) {

<section className="grid">
<div className="card stack">
<h2 className="lbl">Solved</h2>
<h2 className="lbl">
<Link className={styles.statLink} to="/?status=solved">
Solved
</Link>
</h2>
<Stat value={solved} of={total} />
<Meter ratio={solved / total} />
</div>
{DIFFS.map(([k, label, color]) => (
<div className="card stack" key={k}>
<h2 className="lbl">{label}</h2>
<h2 className="lbl">
<Link className={styles.statLink} to={`/?status=solved&difficulty=${label}`}>
{label}
</Link>
</h2>
<Stat value={s.totals[k][0]} of={s.totals[k][1]} />
<Meter ratio={s.totals[k][0] / s.totals[k][1]} color={color} />
</div>
Expand Down Expand Up @@ -94,6 +106,8 @@ function ProgressView({ stats: s }: { stats: Stats }) {
</div>
</section>

<SolvedList solved={s.solved} />

<section className={`grid ${styles.wideGrid}`}>
<div className="card">
<CardHeader title="By topic" note="solved / total" />
Expand Down Expand Up @@ -165,3 +179,38 @@ function ProgressView({ stats: s }: { stats: Stats }) {
</main>
);
}

function SolvedList({ solved }: { solved: Stats["solved"] }) {
const [showAll, setShowAll] = useState(false);
const rows = showAll ? solved : solved.slice(0, SOLVED_PREVIEW);
return (
<section className="card">
<CardHeader title="Solved problems" note="Most recent first">
<div className="spacer" />
{solved.length > 0 && (
<Link className="text-sm" to="/?status=solved">
Open in Problems →
</Link>
)}
</CardHeader>
{solved.length === 0 && <span className="note">Problems you get Accepted on will be listed here.</span>}
{rows.map((p) => (
<div key={p.slug} className={styles.solvedRow}>
<Link className="fg strong" to={`/problems/${p.slug}`}>
{problemLabel(p)}
</Link>
<DiffText d={p.difficulty} />
<span className="note">{p.langs.map((l) => LANG_LABEL[l]).join(", ")}</span>
<time className={styles.count} dateTime={p.first_accepted_at}>
{new Date(p.first_accepted_at).toLocaleDateString()}
</time>
</div>
))}
{solved.length > SOLVED_PREVIEW && (
<button className="btn ghost text-sm" onClick={() => setShowAll(!showAll)}>
{showAll ? "Show fewer" : `Show all ${solved.length.toLocaleString()}`}
</button>
)}
</section>
);
}