feat(cli): add solution activity tracking and ordering for test/submit commands

This commit is contained in:
Prad Nukala
2026-08-26 15:50:58 -04:00
parent 1b5bdc4ceb
commit f086c2b39a
2 changed files with 110 additions and 6 deletions
+51
View File
@@ -10,6 +10,12 @@
* are ever fetched, and paid-only problems are skipped because GraphQL * are ever fetched, and paid-only problems are skipped because GraphQL
* returns null content for them. * returns null content for them.
* *
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
* `created_at` (backfilled from the file's birthtime on every picker run),
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
* The test picker sorts by last_tested, the submit picker by created_at, and
* submit hides anything with a last_submitted.
*
* Library: side-effect-free on import — nothing is opened or fetched until * Library: side-effect-free on import — nothing is opened or fetched until
* problemDb() is called. Index upserts never touch the description column, so * problemDb() is called. Index upserts never touch the description column, so
* a daily refresh cannot evict cached statements. * a daily refresh cannot evict cached statements.
@@ -27,6 +33,13 @@ export interface Problem {
paidOnly: boolean; paidOnly: boolean;
} }
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
export interface SolutionActivity {
createdAt: string;
lastTested: string | null;
lastSubmitted: string | null;
}
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const; const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
const INDEX_TTL_MS = 24 * 60 * 60 * 1000; const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
@@ -109,6 +122,13 @@ export function problemDb(path = DB_PATH) {
paid_only INTEGER NOT NULL, paid_only INTEGER NOT NULL,
description TEXT description TEXT
); );
CREATE TABLE IF NOT EXISTS solutions (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
last_tested TEXT,
last_submitted TEXT
);
DROP TABLE IF EXISTS submissions;
CREATE TABLE IF NOT EXISTS meta ( CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
@@ -177,6 +197,37 @@ export function problemDb(path = DB_PATH) {
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html); db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
}, },
/** Per-solution lifecycle rows keyed by LC number. */
solutionActivity(): Map<number, SolutionActivity> {
const rows = db
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
)
.all();
return new Map(
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
);
},
/** Register a solution's creation time; existing rows are left alone. */
ensureSolution(id: number, createdAt: string): void {
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
},
markTested(id: number): void {
db.query(
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
).run(id, new Date().toISOString());
},
markSubmitted(id: number): void {
db.query(
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
).run(id, new Date().toISOString());
},
close(): void { close(): void {
db.close(); db.close();
}, },
+59 -6
View File
@@ -1,18 +1,64 @@
/** /**
* Shared work/ solution picker for the test and submit entries: fuzzy-select * Shared work/ solution picker for the test and submit entries: fuzzy-select
* a solution file, then hand it to leetcode-cli. Library: side-effect-free on * a solution file, then hand it to leetcode-cli.
* import — nothing scans the filesystem until runOnSolution() is called. *
* Every run backfills the db's solutions table with created_at (from the
* file's birthtime, mtime when the filesystem has none) so ordering works for
* files that predate the db. Ordering is per command — test lists the most
* recently tested first (never-tested after, by name); submit lists the most
* recently created first. Nothing is ever hidden. Exit 0 of leetcode-cli
* stamps last_tested / last_submitted.
*
* Library: side-effect-free on import — nothing scans the filesystem until
* runOnSolution() is called.
*/ */
import { autocomplete, cancel, isCancel, log } from "@clack/prompts"; import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
import { join, relative } from "node:path"; import { statSync } from "node:fs";
import { basename, join, relative } from "node:path";
import { problemDb } from "./db";
const ROOT = join(import.meta.dir, "..", ".."); const ROOT = join(import.meta.dir, "..", "..");
const WORK_DIR = join(ROOT, "work"); const WORK_DIR = join(ROOT, "work");
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
function lcNumber(rel: string): number | undefined {
const m = basename(rel).match(/^(\d+)\./);
return m ? Number(m[1]) : undefined;
}
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */ /** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> { export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })] const db = problemDb();
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const scanned = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })];
// register creation times so files older than the db still sort correctly
for (const f of scanned) {
const num = lcNumber(f);
if (num === undefined) continue;
const stat = statSync(join(WORK_DIR, f));
db.ensureSolution(num, new Date(stat.birthtimeMs || stat.mtimeMs).toISOString());
}
const activity = db.solutionActivity();
// test → most recently tested first; submit → most recently created first
const sortKey = (f: string) => {
const num = lcNumber(f);
const row = num === undefined ? undefined : activity.get(num);
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
};
const files = scanned
.sort((a, b) => {
const ka = sortKey(a);
const kb = sortKey(b);
if (ka !== kb) {
if (ka === undefined) return 1;
if (kb === undefined) return -1;
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
}
return a.localeCompare(b, undefined, { numeric: true });
});
if (files.length === 0) { if (files.length === 0) {
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`); log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
@@ -55,5 +101,12 @@ export async function runOnSolution(command: "test" | "submit", message: string)
], ],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
); );
process.exit(await proc.exited); const code = await proc.exited;
const num = lcNumber(picked);
if (code === 0 && num !== undefined) {
if (command === "test") db.markTested(num);
else db.markSubmitted(num);
}
db.close();
process.exit(code);
} }