@devds1989/t-rush 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Devesh Chandra Srivastava
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@devds1989/t-rush",
3
+ "version": "1.0.0",
4
+ "description": "Speedrun your TODOs, FIXMEs, and BUGs.",
5
+ "type": "module",
6
+ "bin": {
7
+ "trush": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "dev": "tsx src/index.ts"
14
+ },
15
+ "keywords": [
16
+ "todo",
17
+ "fixme",
18
+ "cli",
19
+ "productivity",
20
+ "speedrun",
21
+ "developer-tools"
22
+ ],
23
+ "author": "Devesh",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@inquirer/confirm": "^5.1.9",
27
+ "@inquirer/search": "^4.1.9",
28
+ "chalk": "^5.6.2",
29
+ "commander": "^14.0.3",
30
+ "fast-glob": "^3.3.3",
31
+ "ignore": "^7.0.5",
32
+ "nanospinner": "^1.2.2"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^25.8.0",
36
+ "tsx": "^4.22.1",
37
+ "typescript": "^6.0.3"
38
+ }
39
+ }
@@ -0,0 +1,107 @@
1
+ import chalk from "chalk";
2
+ import { loadData } from "../store.js";
3
+ import { badge } from "../ui.js";
4
+ import type { RunRecord } from "../types/types.js";
5
+
6
+ function formatDate(iso: string): string {
7
+ const d = new Date(iso);
8
+ return d.toLocaleDateString("en-GB", {
9
+ day: "2-digit",
10
+ month: "short",
11
+ year: "numeric",
12
+ });
13
+ }
14
+
15
+ function formatDuration(seconds: number): string {
16
+ const m = Math.floor(seconds / 60);
17
+ const s = seconds % 60;
18
+ if (m > 0) return `${m}m ${s}s`;
19
+ return `${s}s`;
20
+ }
21
+
22
+ function statusIcon(completed: boolean): string {
23
+ return completed ? chalk.green("✔") : chalk.red("✘");
24
+ }
25
+
26
+ function printRow(run: RunRecord): void {
27
+ const date = chalk.dim(formatDate(run.startedAt));
28
+ const status = statusIcon(run.completed);
29
+ const type = badge(run.type);
30
+ const file = chalk.dim(run.file + ":" + run.line);
31
+ const text = chalk.white(
32
+ run.text.length > 40 ? run.text.slice(0, 40) + "…" : run.text,
33
+ );
34
+ const duration = chalk.cyan(formatDuration(run.duration));
35
+ const repo = chalk.gray(`[${run.repo}]`);
36
+
37
+ console.log(` ${status} ${date} ${type} ${duration} ${file}`);
38
+ console.log(` ${text} ${repo}`);
39
+ console.log();
40
+ }
41
+
42
+ type HistoryOptions = {
43
+ limit?: number;
44
+ type?: string;
45
+ repo?: string;
46
+ completed?: boolean;
47
+ };
48
+
49
+ function filterRuns(runs: RunRecord[], opts: HistoryOptions): RunRecord[] {
50
+ let filtered = [...runs].reverse(); // most recent first
51
+
52
+ if (opts.type) {
53
+ const t = opts.type.toUpperCase();
54
+ filtered = filtered.filter((r) => r.type === t);
55
+ }
56
+
57
+ if (opts.repo) {
58
+ filtered = filtered.filter((r) => r.repo.includes(opts.repo!));
59
+ }
60
+
61
+ if (opts.completed !== undefined) {
62
+ filtered = filtered.filter((r) => r.completed === opts.completed);
63
+ }
64
+
65
+ if (opts.limit) {
66
+ filtered = filtered.slice(0, opts.limit);
67
+ }
68
+
69
+ return filtered;
70
+ }
71
+
72
+ export function history(opts: HistoryOptions = {}): void {
73
+ const data = loadData();
74
+
75
+ if (data.runs.length === 0) {
76
+ console.log();
77
+ console.log(chalk.gray(" No runs yet. Start one with: trush start"));
78
+ console.log();
79
+ return;
80
+ }
81
+
82
+ const runs = filterRuns(data.runs, { limit: 20, ...opts });
83
+
84
+ if (runs.length === 0) {
85
+ console.log();
86
+ console.log(chalk.gray(" No runs match that filter."));
87
+ console.log();
88
+ return;
89
+ }
90
+
91
+ const width = 50;
92
+ const line = chalk.gray("─".repeat(width));
93
+
94
+ console.log();
95
+ console.log(chalk.white.bold(" Run History"));
96
+ console.log(
97
+ chalk.gray(` showing ${runs.length} of ${data.runs.length} total`),
98
+ );
99
+ console.log();
100
+ console.log(line);
101
+ console.log();
102
+
103
+ runs.forEach(printRow);
104
+
105
+ console.log(line);
106
+ console.log();
107
+ }
@@ -0,0 +1,145 @@
1
+ import { createSpinner } from "nanospinner";
2
+ import search from "@inquirer/search";
3
+ import chalk from "chalk";
4
+ import path from "path";
5
+
6
+ import { scanRepo } from "../scanner.js";
7
+ import { openEditor } from "../editor.js";
8
+ import { validateTodo } from "../validator.js";
9
+ import { createTimer } from "../timer.js";
10
+ import { addRun, generateId, getRepoName } from "../store.js";
11
+ import { incrementStreak, resetStreak, getStats } from "../streak.js";
12
+ import {
13
+ badge,
14
+ formatTodoChoice,
15
+ formatValidation,
16
+ printResult,
17
+ printNoTodos,
18
+ warn,
19
+ } from "../ui.js";
20
+ import type { TodoItem } from "../types/types.js";
21
+
22
+ async function pickTodo(items: TodoItem[]): Promise<TodoItem> {
23
+ const answer = await search<TodoItem>({
24
+ message: "Pick a TODO, FIXME, or BUG to fix",
25
+ source: async (input) => {
26
+ const term = (input ?? "").toLowerCase();
27
+ return items
28
+ .filter(
29
+ (item) =>
30
+ item.text.toLowerCase().includes(term) ||
31
+ item.file.toLowerCase().includes(term) ||
32
+ item.type.toLowerCase().includes(term),
33
+ )
34
+ .map((item) => ({
35
+ name: formatTodoChoice(item),
36
+ value: item,
37
+ }));
38
+ },
39
+ });
40
+
41
+ return answer;
42
+ }
43
+
44
+ async function confirmComplete(): Promise<boolean> {
45
+ const confirm = (await import("@inquirer/confirm")).default;
46
+ return confirm({ message: "Did you complete it?" });
47
+ }
48
+
49
+ async function confirmAnyway(): Promise<boolean> {
50
+ const confirm = (await import("@inquirer/confirm")).default;
51
+ return confirm({
52
+ message: chalk.yellow("TODO still present. Mark as complete anyway?"),
53
+ default: false,
54
+ });
55
+ }
56
+
57
+ export async function start(cwd: string = process.cwd()): Promise<void> {
58
+ // 1. scan
59
+ const scanSpinner = createSpinner("Scanning repo...").start();
60
+ const items = await scanRepo(cwd);
61
+
62
+ if (items.length === 0) {
63
+ scanSpinner.success({ text: "Scan complete" });
64
+ printNoTodos();
65
+ return;
66
+ }
67
+
68
+ scanSpinner.success({
69
+ text: `Found ${chalk.white.bold(items.length)} items`,
70
+ });
71
+
72
+ // 2. pick
73
+ const picked = await pickTodo(items);
74
+ const filePath = path.resolve(cwd, picked.file);
75
+
76
+ console.log();
77
+ console.log(
78
+ ` ${badge(picked.type)} ${chalk.dim(picked.file)}${chalk.dim(":") + chalk.cyan(picked.line)}`,
79
+ );
80
+ console.log(` ${chalk.gray('"' + picked.text + '"')}`);
81
+ console.log();
82
+
83
+ // 3. open editor + timer
84
+ const timerSpinner = createSpinner(
85
+ "Timer running — close editor to stop",
86
+ ).start();
87
+ const timer = createTimer();
88
+ openEditor(filePath, picked.line);
89
+ const timerResult = timer.stop();
90
+ timerSpinner.success({ text: chalk.white(timerResult.formatted) });
91
+
92
+ // 4. validate
93
+ const validateSpinner = createSpinner("Validating...").start();
94
+ await new Promise((res) => setTimeout(res, 600)); // brief pause so it feels intentional
95
+ const validation = validateTodo(
96
+ filePath,
97
+ picked.line,
98
+ picked.rawLine,
99
+ picked.type,
100
+ );
101
+ validateSpinner.stop();
102
+
103
+ console.log(` ${formatValidation(validation)}`);
104
+ console.log();
105
+
106
+ // 5. confirm
107
+ let completed = false;
108
+
109
+ if (validation.status === "done") {
110
+ completed = await confirmComplete();
111
+ } else if (validation.status === "unchanged") {
112
+ console.log(` ${warn("Still seeing the " + picked.type)}`);
113
+ completed = await confirmAnyway();
114
+ } else if (validation.status === "missing") {
115
+ completed = await confirmComplete();
116
+ }
117
+
118
+ // 6. update streak + stats
119
+ const streak = completed ? incrementStreak(picked.type) : resetStreak();
120
+
121
+ const stats = getStats();
122
+
123
+ // 7. save run
124
+ addRun({
125
+ id: generateId(),
126
+ repo: getRepoName(cwd),
127
+ file: picked.file,
128
+ line: picked.line,
129
+ text: picked.text,
130
+ type: picked.type,
131
+ startedAt: timerResult.startedAt,
132
+ finishedAt: timerResult.finishedAt,
133
+ duration: timerResult.duration,
134
+ completed,
135
+ });
136
+
137
+ // 8. result card
138
+ printResult({
139
+ item: picked,
140
+ duration: timerResult.formatted,
141
+ streak,
142
+ stats,
143
+ completed,
144
+ });
145
+ }
@@ -0,0 +1,109 @@
1
+ import chalk from "chalk";
2
+ import { loadData } from "../store.js";
3
+ import { getStreak, getStats } from "../streak.js";
4
+ import { CommentType } from "../types/types.js";
5
+ import type { RunRecord } from "../types/types.js";
6
+
7
+ // ── Helpers ──────────────────────────────────────────────────────────────────
8
+
9
+ function avgDuration(runs: RunRecord[]): string {
10
+ const completed = runs.filter((r) => r.completed && r.duration > 0);
11
+ if (completed.length === 0) return "N/A";
12
+
13
+ const avg = Math.floor(
14
+ completed.reduce((acc, r) => acc + r.duration, 0) / completed.length,
15
+ );
16
+ const m = Math.floor(avg / 60);
17
+ const s = avg % 60;
18
+
19
+ if (m > 0) return `${m}m ${s}s`;
20
+ return `${s}s`;
21
+ }
22
+
23
+ function fastestRun(runs: RunRecord[]): string {
24
+ const completed = runs.filter((r) => r.completed && r.duration > 0);
25
+ if (completed.length === 0) return "N/A";
26
+
27
+ const fastest = completed.reduce((a, b) => (a.duration < b.duration ? a : b));
28
+ const m = Math.floor(fastest.duration / 60);
29
+ const s = fastest.duration % 60;
30
+
31
+ const time = m > 0 ? `${m}m ${s}s` : `${s}s`;
32
+ return `${time} ${chalk.dim(`(${fastest.file}:${fastest.line})`)}`;
33
+ }
34
+
35
+ function completionRate(runs: RunRecord[]): string {
36
+ if (runs.length === 0) return "N/A";
37
+ const rate = Math.round(
38
+ (runs.filter((r) => r.completed).length / runs.length) * 100,
39
+ );
40
+ const color =
41
+ rate >= 80 ? chalk.green : rate >= 50 ? chalk.yellow : chalk.red;
42
+ return color(`${rate}%`);
43
+ }
44
+
45
+ function topRepo(runs: RunRecord[]): string {
46
+ if (runs.length === 0) return "N/A";
47
+
48
+ const counts: Record<string, number> = {};
49
+ runs.forEach((r) => {
50
+ counts[r.repo] = (counts[r.repo] ?? 0) + 1;
51
+ });
52
+
53
+ const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
54
+ return `${chalk.white(top[0])} ${chalk.dim(`(${top[1]} runs)`)}`;
55
+ }
56
+
57
+ function row(label: string, value: string): void {
58
+ const padded = label.padEnd(20, " ");
59
+ console.log(` ${chalk.dim(padded)} ${value}`);
60
+ }
61
+
62
+ // ── Main ─────────────────────────────────────────────────────────────────────
63
+
64
+ export function stats(): void {
65
+ const data = loadData();
66
+ const streak = getStreak();
67
+ const s = getStats();
68
+ const runs = data.runs;
69
+
70
+ const width = 50;
71
+ const line = chalk.gray("─".repeat(width));
72
+
73
+ console.log();
74
+ console.log(chalk.white.bold(" Stats"));
75
+ console.log();
76
+ console.log(line);
77
+ console.log();
78
+
79
+ // streak
80
+ console.log(` ${chalk.yellow.bold("Streak")}`);
81
+ row("Current", chalk.white.bold(String(streak.current)));
82
+ row("Last", chalk.white(String(streak.last)));
83
+ row("Longest", chalk.white(String(streak.longest)));
84
+ console.log();
85
+
86
+ // runs
87
+ console.log(` ${chalk.cyan.bold("Runs")}`);
88
+ row("Completed", chalk.green(String(s.totalCompleted)));
89
+ row("Aborted", chalk.red(String(s.totalAborted)));
90
+ row("Completion rate", completionRate(runs));
91
+ console.log();
92
+
93
+ // by type
94
+ console.log(` ${chalk.white.bold("By Type")}`);
95
+ row("TODO", chalk.blue(String(s.byType[CommentType.TODO])));
96
+ row("FIXME", chalk.yellow(String(s.byType[CommentType.FIXME])));
97
+ row("BUG", chalk.red(String(s.byType[CommentType.BUG])));
98
+ console.log();
99
+
100
+ // performance
101
+ console.log(` ${chalk.white.bold("Performance")}`);
102
+ row("Avg time", chalk.white(avgDuration(runs)));
103
+ row("Fastest run", fastestRun(runs));
104
+ row("Top repo", topRepo(runs));
105
+ console.log();
106
+
107
+ console.log(line);
108
+ console.log();
109
+ }
package/src/editor.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import type { SpawnSyncOptions } from "node:child_process";
3
+ import { EditorConfig, SupportedEditors } from "./types/types";
4
+
5
+ const EDITORS: Record<SupportedEditors, EditorConfig> = {
6
+ nvim: {
7
+ bin: {
8
+ linux: "nvim",
9
+ win32: "nvim.exe",
10
+ },
11
+ args: (file, line) => [`+${line}`, file],
12
+ },
13
+ code: {
14
+ bin: {
15
+ linux: "code",
16
+ win32: "code.cmd",
17
+ },
18
+ args: (file, line) => ["--goto", `${file}:${line}`],
19
+ },
20
+ };
21
+ type Platform = "linux" | "win32";
22
+
23
+ function getPlatform(): Platform {
24
+ return process.platform === "win32" ? "win32" : "linux";
25
+ }
26
+
27
+ function detectEditor(): SupportedEditors {
28
+ const env = process.env.EDITOR ?? "";
29
+
30
+ if (env.includes("nvim")) return "nvim";
31
+ if (env.includes("code")) return "code";
32
+
33
+ // fallback: default per platform
34
+ return getPlatform() === "win32" ? "code" : "nvim";
35
+ }
36
+
37
+ function getBin(editor: SupportedEditors): string {
38
+ return EDITORS[editor].bin[getPlatform()];
39
+ }
40
+
41
+ function getArgs(
42
+ editor: SupportedEditors,
43
+ file: string,
44
+ line: number,
45
+ ): string[] {
46
+ return EDITORS[editor].args(file, line);
47
+ }
48
+
49
+ export function openEditor(file: string, line: number): void {
50
+ const editor = detectEditor();
51
+ const bin = getBin(editor);
52
+ const args = getArgs(editor, file, line);
53
+
54
+ const options: SpawnSyncOptions = {
55
+ stdio: "inherit",
56
+ // code on windows needs a shell to resolve .cmd
57
+ shell: getPlatform() === "win32" && editor === "code",
58
+ };
59
+
60
+ const result = spawnSync(bin, args, options);
61
+
62
+ if (result.error) {
63
+ throw new Error(`Failed to open editor: ${result.error.message}`);
64
+ }
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env -S npx tsx
2
+
3
+ import { Command } from "commander";
4
+ import { start } from "./commands/start.js";
5
+ import { history } from "./commands/history.js";
6
+ import { stats } from "./commands/stats.js";
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name("t-rush")
12
+ .description("Speedrun your TODOs, FIXMEs, and BUGs")
13
+ .version("0.1.0");
14
+
15
+ program
16
+ .command("start")
17
+ .description("scan repo, pick a TODO and start a speedrun")
18
+ .argument("[dir]", "directory to scan", process.cwd())
19
+ .action(async (dir: string) => {
20
+ await start(dir);
21
+ });
22
+
23
+ program
24
+ .command("history")
25
+ .description("show past runs")
26
+ .option("-l, --limit <n>", "number of runs to show", "20")
27
+ .option("-t, --type <type>", "filter by type: TODO, FIXME, BUG")
28
+ .option("-r, --repo <name>", "filter by repo name")
29
+ .option("--completed", "show only completed runs")
30
+ .option("--aborted", "show only aborted runs")
31
+ .action((opts) => {
32
+ history({
33
+ limit: parseInt(opts.limit),
34
+ type: opts.type,
35
+ repo: opts.repo,
36
+ completed: opts.completed ? true : opts.aborted ? false : undefined,
37
+ });
38
+ });
39
+
40
+ program
41
+ .command("stats")
42
+ .description("show streak, completion rate and performance")
43
+ .action(() => {
44
+ stats();
45
+ });
46
+
47
+ program.parse();
package/src/scanner.ts ADDED
@@ -0,0 +1,136 @@
1
+ import fg from "fast-glob";
2
+ import ignore from "ignore";
3
+ import fs from "fs";
4
+ import path from "path";
5
+
6
+ export type TodoItem = {
7
+ type: "TODO" | "FIXME";
8
+ file: string;
9
+ line: number;
10
+ text: string;
11
+ rawLine: string;
12
+ };
13
+
14
+ const ALWAYS_IGNORE = [
15
+ "**/node_modules/**",
16
+ "**/.git/**",
17
+ "**/dist/**",
18
+ "**/build/**",
19
+ "**/.next/**",
20
+ "**/coverage/**",
21
+ "**/*.min.js",
22
+ "**/*.lock",
23
+ ];
24
+
25
+ const TODO_REGEX =
26
+ /(?:\/\/|#|--|%|;|\*)\s*(?:TODO|FIXME)(?:\(.*?\))?[:\s]+(.+)/i;
27
+
28
+ const TODO_TYPE_REGEX = /(?:TODO|FIXME)/i;
29
+
30
+ const BINARY_EXTENSIONS = new Set([
31
+ "png",
32
+ "jpg",
33
+ "jpeg",
34
+ "gif",
35
+ "webp",
36
+ "ico",
37
+ "svg",
38
+ "pdf",
39
+ "zip",
40
+ "tar",
41
+ "gz",
42
+ "rar",
43
+ "7z",
44
+ "exe",
45
+ "bin",
46
+ "dll",
47
+ "so",
48
+ "dylib",
49
+ "mp3",
50
+ "mp4",
51
+ "wav",
52
+ "mov",
53
+ "avi",
54
+ "ttf",
55
+ "woff",
56
+ "woff2",
57
+ "eot",
58
+ "lock",
59
+ "map",
60
+ ]);
61
+
62
+ function isBinary(filePath: string): boolean {
63
+ const ext = path.extname(filePath).replace(".", "").toLowerCase();
64
+ return BINARY_EXTENSIONS.has(ext);
65
+ }
66
+
67
+ function loadGitignore(cwd: string): ReturnType<typeof ignore> {
68
+ const ig = ignore();
69
+ const gitignorePath = path.join(cwd, ".gitignore");
70
+
71
+ if (fs.existsSync(gitignorePath)) {
72
+ const content = fs.readFileSync(gitignorePath, "utf-8");
73
+ ig.add(content);
74
+ }
75
+
76
+ return ig;
77
+ }
78
+
79
+ function parseTodos(filePath: string, cwd: string): TodoItem[] {
80
+ const items: TodoItem[] = [];
81
+
82
+ if (isBinary(filePath)) return items;
83
+
84
+ let content: string;
85
+ try {
86
+ content = fs.readFileSync(filePath, "utf-8");
87
+ } catch {
88
+ return items;
89
+ }
90
+
91
+ // skip if file looks binary despite extension
92
+ if (content.includes("\0")) return items;
93
+
94
+ const lines = content.split("\n");
95
+
96
+ lines.forEach((rawLine, index) => {
97
+ const match = TODO_REGEX.exec(rawLine);
98
+ if (!match) return;
99
+
100
+ const typeMatch = TODO_TYPE_REGEX.exec(rawLine);
101
+ if (!typeMatch) return;
102
+
103
+ items.push({
104
+ type: typeMatch[0].toUpperCase() as "TODO" | "FIXME",
105
+ file: path.relative(cwd, filePath),
106
+ line: index + 1,
107
+ text: match[1].trim(),
108
+ rawLine: rawLine.trim(),
109
+ });
110
+ });
111
+
112
+ return items;
113
+ }
114
+
115
+ export async function scanRepo(
116
+ cwd: string = process.cwd(),
117
+ ): Promise<TodoItem[]> {
118
+ const ig = loadGitignore(cwd);
119
+
120
+ const files = await fg("**/*", {
121
+ cwd,
122
+ absolute: true,
123
+ onlyFiles: true,
124
+ ignore: ALWAYS_IGNORE,
125
+ dot: false,
126
+ });
127
+
128
+ const filtered = files.filter((f) => {
129
+ const rel = path.relative(cwd, f);
130
+ return !ig.ignores(rel);
131
+ });
132
+
133
+ const todos = filtered.flatMap((f) => parseTodos(f, cwd));
134
+
135
+ return todos;
136
+ }
package/src/store.ts ADDED
@@ -0,0 +1,66 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { RunRecord, AppData } from "./types/types";
5
+ import { CommentType } from "./types/types";
6
+
7
+ const DATA_DIR = path.join(os.homedir(), ".t-rush");
8
+ const DATA_FILE = path.join(DATA_DIR, "data.json");
9
+
10
+ const DEFAULT_DATA: AppData = {
11
+ runs: [],
12
+ streak: {
13
+ current: 0,
14
+ last: 0,
15
+ longest: 0,
16
+ },
17
+ stats: {
18
+ totalCompleted: 0,
19
+ totalAborted: 0,
20
+ byType: {
21
+ [CommentType.TODO]: 0,
22
+ [CommentType.FIXME]: 0,
23
+ [CommentType.BUG]: 0,
24
+ },
25
+ },
26
+ };
27
+
28
+ export function loadData(): AppData {
29
+ if (!fs.existsSync(DATA_DIR)) return structuredClone(DEFAULT_DATA);
30
+
31
+ try {
32
+ const content = fs.readFileSync(DATA_FILE, "utf-8");
33
+ return JSON.parse(content) as AppData;
34
+ } catch {
35
+ return structuredClone(DEFAULT_DATA);
36
+ }
37
+ }
38
+
39
+ export function saveData(data: AppData): void {
40
+ fs.mkdirSync(DATA_DIR, { recursive: true });
41
+
42
+ const tmp = DATA_FILE + ".tmp";
43
+
44
+ try {
45
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
46
+ fs.renameSync(tmp, DATA_FILE);
47
+ } catch (err) {
48
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ export function addRun(record: RunRecord): void {
54
+ const data = loadData();
55
+ data.runs.push(record);
56
+ saveData(data);
57
+ }
58
+
59
+ export function getRepoName(cwd: string = process.cwd()): string {
60
+ const parts = cwd.split(path.sep);
61
+ return parts[parts.length - 1] || "unknown-repo";
62
+ }
63
+
64
+ export function generateId(): string {
65
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
66
+ }
package/src/streak.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { loadData, saveData } from "./store";
2
+ import { CommentType } from "./types/types";
3
+ import { StreakInfo, StatsInfo } from "./types/types";
4
+
5
+ export function incrementStreak(type: CommentType): StreakInfo {
6
+ const data = loadData();
7
+ data.streak.current += 1;
8
+ data.streak.longest = Math.max(data.streak.longest, data.streak.current);
9
+ data.stats.totalCompleted += 1;
10
+ data.stats.byType[type] += 1;
11
+ saveData(data);
12
+ return { ...data.streak };
13
+ }
14
+
15
+ export function resetStreak(): StreakInfo {
16
+ const data = loadData();
17
+ data.streak.last = data.streak.current;
18
+ data.streak.current = 0;
19
+ data.stats.totalAborted += 1;
20
+ saveData(data);
21
+ return { ...data.streak };
22
+ }
23
+
24
+ export function getStreak(): StreakInfo {
25
+ const data = loadData();
26
+ return { ...data.streak };
27
+ }
28
+
29
+ export function getStats(): StatsInfo {
30
+ const data = loadData();
31
+ return { ...data.stats };
32
+ }
package/src/timer.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { TimerResult } from "./types/types";
2
+
3
+ export function formatDuration(seconds: number): string {
4
+ const h = Math.floor(seconds / 3600);
5
+ const m = Math.floor((seconds % 3600) / 60);
6
+ const s = seconds % 60;
7
+
8
+ if (h > 0) {
9
+ return `${h}h ${m}m ${s}s`;
10
+ } else if (m > 0) {
11
+ return `${m}m ${s}s`;
12
+ }
13
+ return `${s}s`;
14
+ }
15
+
16
+ export function createTimer() {
17
+ const startedAt = new Date();
18
+ const startMs = Date.now();
19
+
20
+ function stop(): TimerResult {
21
+ const finishedAt = new Date();
22
+ const duration = Math.floor((Date.now() - startMs) / 1000);
23
+
24
+ return {
25
+ startedAt: startedAt.toISOString(),
26
+ finishedAt: finishedAt.toISOString(),
27
+ duration,
28
+ formatted: formatDuration(duration),
29
+ };
30
+ }
31
+
32
+ return { stop };
33
+ }
@@ -0,0 +1,70 @@
1
+ export const CommentType = {
2
+ TODO: "TODO",
3
+ FIXME: "FIXME",
4
+ BUG: "BUG",
5
+ } as const;
6
+
7
+ export type CommentType = (typeof CommentType)[keyof typeof CommentType];
8
+
9
+ export type TodoItem = {
10
+ type: CommentType;
11
+ file: string;
12
+ line: number;
13
+ text: string;
14
+ rawLine: string;
15
+ };
16
+
17
+ export type RunRecord = {
18
+ id: string;
19
+ repo: string;
20
+ file: string;
21
+ line: number;
22
+ text: string;
23
+ type: CommentType;
24
+ startedAt: string;
25
+ finishedAt: string;
26
+ duration: number;
27
+ completed: boolean;
28
+ };
29
+
30
+ export type StreakInfo = {
31
+ current: number;
32
+ last: number;
33
+ longest: number;
34
+ };
35
+
36
+ export type StatsInfo = {
37
+ totalCompleted: number;
38
+ totalAborted: number;
39
+ byType: Record<CommentType, number>;
40
+ };
41
+
42
+ export type AppData = {
43
+ runs: RunRecord[];
44
+ streak: StreakInfo;
45
+ stats: StatsInfo;
46
+ };
47
+
48
+ export type TimerResult = {
49
+ startedAt: string;
50
+ finishedAt: string;
51
+ duration: number;
52
+ formatted: string; // formatted like "1m 30s"
53
+ };
54
+
55
+ export type SupportedEditors = "code" | "nvim";
56
+
57
+ export type EditorConfig = {
58
+ bin: {
59
+ linux: string;
60
+ win32: string;
61
+ };
62
+ args: (file: string, line: number) => string[];
63
+ };
64
+
65
+ export type ValidationStatus = "done" | "unchanged" | "missing";
66
+
67
+ export type ValidationResult = {
68
+ status: ValidationStatus;
69
+ message: string;
70
+ };
package/src/ui.ts ADDED
@@ -0,0 +1,124 @@
1
+ import chalk from "chalk";
2
+ import type {
3
+ StreakInfo,
4
+ StatsInfo,
5
+ TodoItem,
6
+ ValidationResult,
7
+ } from "./types/types.js";
8
+ import { CommentType } from "./types/types.js";
9
+
10
+ // colors
11
+ export function badge(type: TodoItem["type"]): string {
12
+ switch (type) {
13
+ case CommentType.TODO:
14
+ return chalk.bgBlue.white.bold(` TODO `);
15
+ case CommentType.FIXME:
16
+ return chalk.bgYellow.black.bold(` FIXME `);
17
+ case CommentType.BUG:
18
+ return chalk.bgRed.white.bold(` BUG `);
19
+ }
20
+ }
21
+
22
+ export function formatTodoChoice(item: TodoItem): string {
23
+ return `${badge(item.type)} ${chalk.dim(item.file)}${chalk.dim(":") + chalk.cyan(item.line)} ${chalk.white(item.text)}`;
24
+ }
25
+
26
+ export function label(text: string): string {
27
+ return chalk.gray(text);
28
+ }
29
+
30
+ export function success(text: string): string {
31
+ return chalk.green(`✔ ${text}`);
32
+ }
33
+
34
+ export function failure(text: string): string {
35
+ return chalk.red(`✘ ${text}`);
36
+ }
37
+
38
+ export function warn(text: string): string {
39
+ return chalk.yellow(`⚠ ${text}`);
40
+ }
41
+
42
+ export function info(text: string): string {
43
+ return chalk.cyan(`ℹ ${text}`);
44
+ }
45
+
46
+ export function formatValidation(result: ValidationResult): string {
47
+ switch (result.status) {
48
+ case "done":
49
+ return success(result.message);
50
+ case "unchanged":
51
+ return warn(result.message);
52
+ case "missing":
53
+ return info(result.message);
54
+ }
55
+ }
56
+
57
+ type ResultCardOptions = {
58
+ item: TodoItem;
59
+ duration: string;
60
+ streak: StreakInfo;
61
+ stats: StatsInfo;
62
+ completed: boolean;
63
+ };
64
+
65
+ export function printResult(opts: ResultCardOptions): void {
66
+ const { item, duration, streak, stats, completed } = opts;
67
+
68
+ const width = 50;
69
+ const line = chalk.gray("─".repeat(width));
70
+
71
+ console.log();
72
+ console.log(line);
73
+ console.log();
74
+
75
+ // status
76
+ if (completed) {
77
+ console.log(
78
+ ` ${chalk.green.bold("✔")} ${badge(item.type)} ${chalk.white.bold("resolved")}`,
79
+ );
80
+ } else {
81
+ console.log(
82
+ ` ${chalk.red.bold("✘")} ${badge(item.type)} ${chalk.white.bold("aborted")}`,
83
+ );
84
+ }
85
+
86
+ // file + text
87
+ console.log(` ${chalk.dim(item.file + ":" + item.line)}`);
88
+ console.log(` ${chalk.gray('"' + item.text + '"')}`);
89
+ console.log();
90
+
91
+ // time
92
+ console.log(` ${chalk.cyan("⏱")} ${chalk.white(duration)}`);
93
+ console.log();
94
+
95
+ // streak
96
+ if (completed) {
97
+ console.log(
98
+ ` ${chalk.yellow("")} Streak: ${chalk.white.bold(streak.current)} ${chalk.dim(`(longest: ${streak.longest})`)}`,
99
+ );
100
+ } else {
101
+ console.log(
102
+ ` ${chalk.gray("")} Streak reset ${chalk.dim(`(last: ${streak.last} longest: ${streak.longest})`)}`,
103
+ );
104
+ }
105
+
106
+ // stats
107
+ console.log();
108
+ console.log(
109
+ ` ${chalk.dim("TODO:")} ${chalk.white(stats.byType[CommentType.TODO])} ${chalk.dim("FIXME:")} ${chalk.white(stats.byType[CommentType.FIXME])} ${chalk.dim("BUG:")} ${chalk.white(stats.byType[CommentType.BUG])}`,
110
+ );
111
+ console.log(
112
+ ` ${chalk.dim("Completed:")} ${chalk.green(stats.totalCompleted)} ${chalk.dim("Aborted:")} ${chalk.red(stats.totalAborted)}`,
113
+ );
114
+
115
+ console.log();
116
+ console.log(line);
117
+ console.log();
118
+ }
119
+
120
+ export function printNoTodos(): void {
121
+ console.log();
122
+ console.log(success("No TODOs, FIXMEs, or BUGs found. Clean codebase!"));
123
+ console.log();
124
+ }
@@ -0,0 +1,61 @@
1
+ import fs from "fs";
2
+ import { CommentType } from "./types/types.js";
3
+ import { ValidationResult } from "./types/types.js";
4
+
5
+ const COMMENT_REGEX = /(?:TODO|FIXME|BUG)(?:\(.*?\))?[:\s]/i;
6
+
7
+ function scanWindow(
8
+ lines: string[],
9
+ line: number,
10
+ originalText: string,
11
+ ): boolean {
12
+ const start = Math.max(0, line - 10);
13
+ const end = Math.min(lines.length, line + 10);
14
+ const window = lines.slice(start, end);
15
+
16
+ return window.some((l) => l.includes(originalText.trim()));
17
+ }
18
+
19
+ export function validateTodo(
20
+ file: string,
21
+ line: number,
22
+ originalText: string,
23
+ type: CommentType,
24
+ ): ValidationResult {
25
+ // file missing or deleted
26
+ if (!fs.existsSync(file)) {
27
+ return {
28
+ status: "missing",
29
+ message: `${file} not found — was it deleted or moved?`,
30
+ };
31
+ }
32
+
33
+ const lines = fs.readFileSync(file, "utf-8").split("\n");
34
+ const exactLine = lines[line - 1]; // 1-indexed
35
+
36
+ // exact line no longer exists (file got shorter)
37
+ if (!exactLine) {
38
+ return {
39
+ status: "done",
40
+ message: `Line ${line} no longer exists — looks done.`,
41
+ };
42
+ }
43
+
44
+ // exact line still has a TODO/FIXME/BUG comment
45
+ if (COMMENT_REGEX.test(exactLine)) {
46
+ // check ±10 window in case user edited around it but didn't remove it
47
+ const foundInWindow = scanWindow(lines, line - 1, originalText);
48
+
49
+ if (foundInWindow) {
50
+ return {
51
+ status: "unchanged",
52
+ message: `${type} still present at ${file}:${line}`,
53
+ };
54
+ }
55
+ }
56
+
57
+ return {
58
+ status: "done",
59
+ message: `${type} resolved in ${file}`,
60
+ };
61
+ }