From f086c2b39aa6965a6403b07555648f1babc3d172 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 26 Aug 2026 15:50:58 -0400 Subject: [PATCH] feat(cli): add solution activity tracking and ordering for test/submit commands --- apps/cli/db.ts | 51 +++++++++++++++++++++++++++++++++ apps/cli/solutions.ts | 65 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/apps/cli/db.ts b/apps/cli/db.ts index 10b955a..c520ea4 100644 --- a/apps/cli/db.ts +++ b/apps/cli/db.ts @@ -10,6 +10,12 @@ * are ever fetched, and paid-only problems are skipped because GraphQL * 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 * problemDb() is called. Index upserts never touch the description column, so * a daily refresh cannot evict cached statements. @@ -27,6 +33,13 @@ export interface Problem { 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 INDEX_TTL_MS = 24 * 60 * 60 * 1000; @@ -109,6 +122,13 @@ export function problemDb(path = DB_PATH) { paid_only INTEGER NOT NULL, 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 ( key TEXT PRIMARY KEY, 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); }, + /** Per-solution lifecycle rows keyed by LC number. */ + solutionActivity(): Map { + 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 { db.close(); }, diff --git a/apps/cli/solutions.ts b/apps/cli/solutions.ts index 10132cb..493be6a 100644 --- a/apps/cli/solutions.ts +++ b/apps/cli/solutions.ts @@ -1,18 +1,64 @@ /** * 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 - * import — nothing scans the filesystem until runOnSolution() is called. + * a solution file, then hand it to leetcode-cli. + * + * 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 { 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 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 ` on it. */ export async function runOnSolution(command: "test" | "submit", message: string): Promise { - const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })] - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + const db = problemDb(); + + 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) { 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"] }, ); - 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); }