hackhours 0.1.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
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/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # HackHours
2
+
3
+ HackHours is a local-first, offline coding activity tracker inspired by WakaTime. It runs as a lightweight background watcher, records activity in ChronoDB, and provides rich CLI summaries.
4
+
5
+ ## Features
6
+ - Local-first, offline-only storage (ChronoDB JSON files)
7
+ - Automatic session tracking with idle detection
8
+ - Language, project, and file breakdowns
9
+ - Daily/weekly/monthly summaries with ASCII charts
10
+ - Cross-platform CLI (Windows/macOS/Linux)
11
+
12
+ ## Install (local dev)
13
+ ```bash
14
+ npm install
15
+ ```
16
+
17
+ ## Build
18
+ ```bash
19
+ npm run build
20
+ ```
21
+
22
+ ## Initialize
23
+ ```bash
24
+ hackhours init
25
+ ```
26
+
27
+ ## Start/Stop Tracking
28
+ ```bash
29
+ hackhours start
30
+ hackhours stop
31
+ ```
32
+
33
+ ## Reports
34
+ ```bash
35
+ hackhours today
36
+ hackhours week
37
+ hackhours month
38
+ hackhours stats --from 2026-02-01 --to 2026-02-28
39
+ hackhours languages --from 2026-02-01 --to 2026-02-28
40
+ hackhours projects --from 2026-02-01 --to 2026-02-28
41
+ ```
42
+
43
+ ## Config
44
+ Config is stored at `~/.hackhours/config.json`:
45
+ ```json
46
+ {
47
+ "directories": ["C:/work/my-project"],
48
+ "idleMinutes": 2,
49
+ "exclude": ["**/node_modules/**", "**/.git/**"],
50
+ "dataDir": "C:/Users/USER/.hackhours/data"
51
+ }
52
+ ```
53
+
54
+ ## Data Storage (ChronoDB)
55
+ Data is stored in `~/.hackhours/data` by default. Collections used:
56
+ - `sessions`
57
+ - `events`
58
+ - `aggregates`
59
+
60
+ ## Shell Completions
61
+ ```bash
62
+ source completions/hackhours.bash
63
+ ```
64
+ For zsh, copy `completions/_hackhours` into your `$fpath`.
65
+
66
+ ## Tests
67
+ ```bash
68
+ npm test
69
+ ```
70
+
71
+ ## Notes
72
+ - `hackhours start` spawns a detached background process.
73
+ - Use `hackhours stop` to end tracking cleanly.
@@ -0,0 +1,34 @@
1
+ #compdef hackhours
2
+
3
+ _hackhours() {
4
+ local -a commands
5
+ commands=(
6
+ "init:Initialize config"
7
+ "start:Start background tracking"
8
+ "stop:Stop background tracking"
9
+ "today:Today's summary"
10
+ "week:Weekly summary"
11
+ "month:Monthly summary"
12
+ "stats:Custom stats"
13
+ "languages:Language breakdown"
14
+ "projects:Project breakdown"
15
+ "files:File breakdown"
16
+ )
17
+
18
+ _arguments -C \
19
+ "1:command:->command" \
20
+ "*::arg:->args"
21
+
22
+ case $state in
23
+ command)
24
+ _describe "command" commands
25
+ ;;
26
+ args)
27
+ _arguments \
28
+ "--from[Start date (YYYY-MM-DD)]" \
29
+ "--to[End date (YYYY-MM-DD)]"
30
+ ;;
31
+ esac
32
+ }
33
+
34
+ _hackhours "$@"
@@ -0,0 +1,18 @@
1
+ _hackhours_completions()
2
+ {
3
+ local cur prev opts
4
+ COMPREPLY=()
5
+ cur="${COMP_WORDS[COMP_CWORD]}"
6
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
7
+ opts="init start stop today week month stats languages projects files --from --to --help --version"
8
+
9
+ if [[ ${cur} == -* ]] ; then
10
+ COMPREPLY=( $(compgen -W "--from --to --help --version" -- ${cur}) )
11
+ return 0
12
+ fi
13
+
14
+ COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
15
+ return 0
16
+ }
17
+
18
+ complete -F _hackhours_completions hackhours
@@ -0,0 +1,10 @@
1
+ import { ChronoCollections } from "../storage/chrono.js";
2
+ export type Summary = {
3
+ totalTimeMs: number;
4
+ filesEdited: Map<string, number>;
5
+ languages: Map<string, number>;
6
+ projects: Map<string, number>;
7
+ activityByHour: number[];
8
+ activityByDay: Map<string, number>;
9
+ };
10
+ export declare const buildSummary: (collections: ChronoCollections, from: number, to: number, idleMinutes: number) => Promise<Summary>;
@@ -0,0 +1,56 @@
1
+ import { getEventsInRange, getSessionsInRange } from "../storage/queries.js";
2
+ import { minutesToMs, toDateKey } from "../utils/time.js";
3
+ const addToMap = (map, key, value) => {
4
+ map.set(key, (map.get(key) ?? 0) + value);
5
+ };
6
+ export const buildSummary = async (collections, from, to, idleMinutes) => {
7
+ const events = await getEventsInRange(collections, from, to);
8
+ const sessions = await getSessionsInRange(collections, from, to);
9
+ const idleMs = minutesToMs(idleMinutes);
10
+ const now = Date.now();
11
+ const sessionsById = new Map(sessions.map((s) => [s.sessionId, s]));
12
+ const eventsBySession = new Map();
13
+ for (const event of events) {
14
+ if (!eventsBySession.has(event.sessionId)) {
15
+ eventsBySession.set(event.sessionId, []);
16
+ }
17
+ eventsBySession.get(event.sessionId)?.push(event);
18
+ }
19
+ const summary = {
20
+ totalTimeMs: 0,
21
+ filesEdited: new Map(),
22
+ languages: new Map(),
23
+ projects: new Map(),
24
+ activityByHour: Array.from({ length: 24 }, () => 0),
25
+ activityByDay: new Map(),
26
+ };
27
+ for (const session of sessions) {
28
+ const sessionEnd = session.endTimestamp ?? now;
29
+ const overlapStart = Math.max(session.startTimestamp, from);
30
+ const overlapEnd = Math.min(sessionEnd, to);
31
+ if (overlapEnd > overlapStart) {
32
+ summary.totalTimeMs += overlapEnd - overlapStart;
33
+ }
34
+ }
35
+ for (const [sessionId, list] of eventsBySession.entries()) {
36
+ const session = sessionsById.get(sessionId);
37
+ const sessionEnd = session?.endTimestamp ?? now;
38
+ const sorted = [...list].sort((a, b) => a.timestamp - b.timestamp);
39
+ for (let i = 0; i < sorted.length; i += 1) {
40
+ const current = sorted[i];
41
+ const next = sorted[i + 1];
42
+ const nextTs = Math.min(next?.timestamp ?? sessionEnd, to);
43
+ const duration = Math.max(0, Math.min(nextTs - current.timestamp, idleMs));
44
+ if (duration <= 0)
45
+ continue;
46
+ addToMap(summary.languages, current.language, duration);
47
+ addToMap(summary.projects, current.project, duration);
48
+ addToMap(summary.filesEdited, current.filePath, duration);
49
+ const eventDate = new Date(current.timestamp);
50
+ const hour = eventDate.getHours();
51
+ summary.activityByHour[hour] += duration;
52
+ addToMap(summary.activityByDay, toDateKey(eventDate), duration);
53
+ }
54
+ }
55
+ return summary;
56
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import Table from "cli-table3";
5
+ import * as asciichart from "asciichart";
6
+ import { spawn } from "node:child_process";
7
+ import path from "node:path";
8
+ import process from "node:process";
9
+ import { loadConfig, promptInitConfig, saveConfig, } from "./config/config.js";
10
+ import { openChrono } from "./storage/chrono.js";
11
+ import { buildSummary } from "./analytics/queries.js";
12
+ import { runWatcher } from "./tracker/watcher.js";
13
+ import { readState, writeState, clearState } from "./utils/state.js";
14
+ import { endOfDay, formatDuration, parseDateKey, startOfDay } from "./utils/time.js";
15
+ const program = new Command();
16
+ const formatBar = (value, total, size = 16) => {
17
+ if (total <= 0)
18
+ return " ".repeat(size);
19
+ const filled = Math.round((value / total) * size);
20
+ return `${"█".repeat(filled)}${" ".repeat(size - filled)}`;
21
+ };
22
+ const printSummary = (title, summary) => {
23
+ const total = summary.totalTimeMs;
24
+ console.log(chalk.bold(`\n${title}`));
25
+ console.log(`${chalk.cyan("Total:")} ${formatDuration(total)}`);
26
+ console.log(`${chalk.cyan("Files edited:")} ${summary.filesEdited.size}`);
27
+ const langEntries = [...summary.languages.entries()].sort((a, b) => b[1] - a[1]);
28
+ if (langEntries.length > 0) {
29
+ console.log(`\n${chalk.cyan("Languages")}`);
30
+ const langTable = new Table({
31
+ head: ["Language", "Time", "%"],
32
+ });
33
+ for (const [lang, duration] of langEntries) {
34
+ langTable.push([lang, formatDuration(duration), `${Math.round((duration / total) * 100)}%`]);
35
+ }
36
+ console.log(langTable.toString());
37
+ }
38
+ console.log(`\n${chalk.cyan("Activity")}`);
39
+ const series = summary.activityByHour.map((ms) => Math.round(ms / 60000));
40
+ if (series.some((v) => v > 0)) {
41
+ console.log(asciichart.plot(series, { height: 8 }));
42
+ }
43
+ else {
44
+ console.log("No activity recorded.");
45
+ }
46
+ };
47
+ const printBreakdown = (title, map, total) => {
48
+ console.log(chalk.bold(`\n${title}`));
49
+ const entries = [...map.entries()].sort((a, b) => b[1] - a[1]);
50
+ if (entries.length === 0) {
51
+ console.log("No activity recorded.");
52
+ return;
53
+ }
54
+ const table = new Table({ head: ["Name", "Time", "%", ""] });
55
+ for (const [name, duration] of entries) {
56
+ const percent = total > 0 ? duration / total : 0;
57
+ table.push([name, formatDuration(duration), `${Math.round(percent * 100)}%`, formatBar(duration, total)]);
58
+ }
59
+ console.log(table.toString());
60
+ };
61
+ const parseRange = (from, to) => {
62
+ if (!from || !to) {
63
+ throw new Error("Both --from and --to are required.");
64
+ }
65
+ const fromDate = startOfDay(parseDateKey(from));
66
+ const toDate = endOfDay(parseDateKey(to));
67
+ return { from: fromDate.getTime(), to: toDate.getTime() };
68
+ };
69
+ program
70
+ .name("hackhours")
71
+ .description("Offline local-first coding activity tracker")
72
+ .version("0.1.0");
73
+ program
74
+ .command("init")
75
+ .description("Initialize HackHours configuration")
76
+ .action(async () => {
77
+ const config = await promptInitConfig();
78
+ saveConfig(config);
79
+ console.log(chalk.green("Saved configuration to ~/.hackhours/config.json"));
80
+ });
81
+ program
82
+ .command("start")
83
+ .description("Start background tracking")
84
+ .action(async () => {
85
+ const existing = readState();
86
+ if (existing) {
87
+ try {
88
+ process.kill(existing.pid, 0);
89
+ console.log(chalk.yellow("HackHours is already running."));
90
+ return;
91
+ }
92
+ catch {
93
+ clearState();
94
+ }
95
+ }
96
+ const node = process.execPath;
97
+ const cli = path.resolve(process.argv[1]);
98
+ const child = spawn(node, [cli, "daemon"], {
99
+ detached: true,
100
+ stdio: "ignore",
101
+ });
102
+ child.unref();
103
+ writeState({ pid: child.pid ?? 0, startedAt: Date.now() });
104
+ console.log(chalk.green("HackHours tracking started."));
105
+ });
106
+ program
107
+ .command("stop")
108
+ .description("Stop background tracking")
109
+ .action(() => {
110
+ const state = readState();
111
+ if (!state) {
112
+ console.log(chalk.yellow("HackHours is not running."));
113
+ return;
114
+ }
115
+ try {
116
+ process.kill(state.pid);
117
+ clearState();
118
+ console.log(chalk.green("HackHours tracking stopped."));
119
+ }
120
+ catch {
121
+ clearState();
122
+ console.log(chalk.yellow("HackHours was not running."));
123
+ }
124
+ });
125
+ program
126
+ .command("daemon")
127
+ .description("Run watcher in foreground (internal)")
128
+ .action(async () => {
129
+ const config = loadConfig();
130
+ const { collections } = await openChrono(config.dataDir);
131
+ const watcher = await runWatcher(config, collections);
132
+ const shutdown = async () => {
133
+ await watcher.stop();
134
+ process.exit(0);
135
+ };
136
+ process.on("SIGINT", shutdown);
137
+ process.on("SIGTERM", shutdown);
138
+ });
139
+ program
140
+ .command("today")
141
+ .description("Show today's summary")
142
+ .action(async () => {
143
+ const config = loadConfig();
144
+ const { collections } = await openChrono(config.dataDir);
145
+ const now = new Date();
146
+ const from = startOfDay(now).getTime();
147
+ const to = endOfDay(now).getTime();
148
+ const summary = await buildSummary(collections, from, to, config.idleMinutes);
149
+ printSummary("HackHours – Today", summary);
150
+ });
151
+ program
152
+ .command("week")
153
+ .description("Show weekly summary")
154
+ .action(async () => {
155
+ const config = loadConfig();
156
+ const { collections } = await openChrono(config.dataDir);
157
+ const now = new Date();
158
+ const fromDate = new Date(now);
159
+ fromDate.setDate(fromDate.getDate() - 6);
160
+ const from = startOfDay(fromDate).getTime();
161
+ const to = endOfDay(now).getTime();
162
+ const summary = await buildSummary(collections, from, to, config.idleMinutes);
163
+ printSummary("HackHours – Last 7 Days", summary);
164
+ });
165
+ program
166
+ .command("month")
167
+ .description("Show monthly summary")
168
+ .action(async () => {
169
+ const config = loadConfig();
170
+ const { collections } = await openChrono(config.dataDir);
171
+ const now = new Date();
172
+ const fromDate = new Date(now);
173
+ fromDate.setDate(fromDate.getDate() - 29);
174
+ const from = startOfDay(fromDate).getTime();
175
+ const to = endOfDay(now).getTime();
176
+ const summary = await buildSummary(collections, from, to, config.idleMinutes);
177
+ printSummary("HackHours – Last 30 Days", summary);
178
+ });
179
+ program
180
+ .command("stats")
181
+ .description("Custom stats for a given range")
182
+ .requiredOption("--from <date>", "Start date (YYYY-MM-DD)")
183
+ .requiredOption("--to <date>", "End date (YYYY-MM-DD)")
184
+ .action(async (options) => {
185
+ const config = loadConfig();
186
+ const { collections } = await openChrono(config.dataDir);
187
+ const range = parseRange(options.from, options.to);
188
+ const summary = await buildSummary(collections, range.from, range.to, config.idleMinutes);
189
+ printSummary(`HackHours – ${options.from} to ${options.to}`, summary);
190
+ });
191
+ program
192
+ .command("languages")
193
+ .description("Language breakdown")
194
+ .option("--from <date>", "Start date (YYYY-MM-DD)")
195
+ .option("--to <date>", "End date (YYYY-MM-DD)")
196
+ .action(async (options) => {
197
+ const config = loadConfig();
198
+ const { collections } = await openChrono(config.dataDir);
199
+ let from;
200
+ let to;
201
+ if (options.from && options.to) {
202
+ const range = parseRange(options.from, options.to);
203
+ from = range.from;
204
+ to = range.to;
205
+ }
206
+ else {
207
+ const now = new Date();
208
+ const fromDate = new Date(now);
209
+ fromDate.setDate(fromDate.getDate() - 6);
210
+ from = startOfDay(fromDate).getTime();
211
+ to = endOfDay(now).getTime();
212
+ }
213
+ const summary = await buildSummary(collections, from, to, config.idleMinutes);
214
+ printBreakdown("Languages", summary.languages, summary.totalTimeMs);
215
+ });
216
+ program
217
+ .command("projects")
218
+ .description("Project breakdown")
219
+ .option("--from <date>", "Start date (YYYY-MM-DD)")
220
+ .option("--to <date>", "End date (YYYY-MM-DD)")
221
+ .action(async (options) => {
222
+ const config = loadConfig();
223
+ const { collections } = await openChrono(config.dataDir);
224
+ let from;
225
+ let to;
226
+ if (options.from && options.to) {
227
+ const range = parseRange(options.from, options.to);
228
+ from = range.from;
229
+ to = range.to;
230
+ }
231
+ else {
232
+ const now = new Date();
233
+ const fromDate = new Date(now);
234
+ fromDate.setDate(fromDate.getDate() - 6);
235
+ from = startOfDay(fromDate).getTime();
236
+ to = endOfDay(now).getTime();
237
+ }
238
+ const summary = await buildSummary(collections, from, to, config.idleMinutes);
239
+ printBreakdown("Projects", summary.projects, summary.totalTimeMs);
240
+ });
241
+ program
242
+ .command("files")
243
+ .description("File breakdown (top 10)")
244
+ .option("--from <date>", "Start date (YYYY-MM-DD)")
245
+ .option("--to <date>", "End date (YYYY-MM-DD)")
246
+ .action(async (options) => {
247
+ const config = loadConfig();
248
+ const { collections } = await openChrono(config.dataDir);
249
+ let from;
250
+ let to;
251
+ if (options.from && options.to) {
252
+ const range = parseRange(options.from, options.to);
253
+ from = range.from;
254
+ to = range.to;
255
+ }
256
+ else {
257
+ const now = new Date();
258
+ const fromDate = new Date(now);
259
+ fromDate.setDate(fromDate.getDate() - 6);
260
+ from = startOfDay(fromDate).getTime();
261
+ to = endOfDay(now).getTime();
262
+ }
263
+ const summary = await buildSummary(collections, from, to, config.idleMinutes);
264
+ const top = [...summary.filesEdited.entries()]
265
+ .sort((a, b) => b[1] - a[1])
266
+ .slice(0, 10);
267
+ const total = summary.totalTimeMs;
268
+ const table = new Table({ head: ["File", "Time", "%"] });
269
+ for (const [file, duration] of top) {
270
+ table.push([file, formatDuration(duration), `${Math.round((duration / total) * 100)}%`]);
271
+ }
272
+ console.log(table.toString());
273
+ });
274
+ program.parseAsync(process.argv);
@@ -0,0 +1,11 @@
1
+ export type HackHoursConfig = {
2
+ directories: string[];
3
+ idleMinutes: number;
4
+ exclude: string[];
5
+ dataDir: string;
6
+ };
7
+ export declare const getDefaultConfig: () => HackHoursConfig;
8
+ export declare const ensureConfigDir: (configPath?: string) => void;
9
+ export declare const loadConfig: (configPath?: string) => HackHoursConfig;
10
+ export declare const saveConfig: (config: HackHoursConfig, configPath?: string) => void;
11
+ export declare const promptInitConfig: () => Promise<HackHoursConfig>;
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import inquirer from "inquirer";
5
+ import { DEFAULT_CONFIG_PATH, DEFAULT_DATA_DIR, DEFAULT_EXCLUDE, DEFAULT_IDLE_MINUTES, } from "./defaults.js";
6
+ export const getDefaultConfig = () => ({
7
+ directories: [process.cwd()],
8
+ idleMinutes: DEFAULT_IDLE_MINUTES,
9
+ exclude: DEFAULT_EXCLUDE,
10
+ dataDir: DEFAULT_DATA_DIR,
11
+ });
12
+ export const ensureConfigDir = (configPath = DEFAULT_CONFIG_PATH) => {
13
+ const dir = path.dirname(configPath);
14
+ if (!fs.existsSync(dir)) {
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ }
17
+ };
18
+ export const loadConfig = (configPath = DEFAULT_CONFIG_PATH) => {
19
+ if (!fs.existsSync(configPath)) {
20
+ return getDefaultConfig();
21
+ }
22
+ const raw = fs.readFileSync(configPath, "utf-8");
23
+ const parsed = JSON.parse(raw);
24
+ return {
25
+ ...getDefaultConfig(),
26
+ ...parsed,
27
+ };
28
+ };
29
+ export const saveConfig = (config, configPath = DEFAULT_CONFIG_PATH) => {
30
+ ensureConfigDir(configPath);
31
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
32
+ };
33
+ export const promptInitConfig = async () => {
34
+ const defaultConfig = getDefaultConfig();
35
+ const answers = await inquirer.prompt([
36
+ {
37
+ type: "input",
38
+ name: "directories",
39
+ message: "Which directories should HackHours track? (comma-separated)",
40
+ default: defaultConfig.directories.join(", "),
41
+ filter: (input) => input
42
+ .split(",")
43
+ .map((v) => v.trim())
44
+ .filter(Boolean),
45
+ },
46
+ {
47
+ type: "number",
48
+ name: "idleMinutes",
49
+ message: "Idle timeout in minutes",
50
+ default: defaultConfig.idleMinutes,
51
+ },
52
+ {
53
+ type: "input",
54
+ name: "exclude",
55
+ message: "Exclude globs (comma-separated)",
56
+ default: defaultConfig.exclude.join(", "),
57
+ filter: (input) => input
58
+ .split(",")
59
+ .map((v) => v.trim())
60
+ .filter(Boolean),
61
+ },
62
+ {
63
+ type: "input",
64
+ name: "dataDir",
65
+ message: "Data directory",
66
+ default: defaultConfig.dataDir,
67
+ filter: (input) => input.trim(),
68
+ },
69
+ ]);
70
+ const expanded = answers.dataDir.replace(/^~\//, `${os.homedir()}/`);
71
+ return {
72
+ directories: answers.directories,
73
+ idleMinutes: answers.idleMinutes,
74
+ exclude: answers.exclude,
75
+ dataDir: expanded,
76
+ };
77
+ };
@@ -0,0 +1,4 @@
1
+ export declare const DEFAULT_IDLE_MINUTES = 2;
2
+ export declare const DEFAULT_CONFIG_PATH: string;
3
+ export declare const DEFAULT_DATA_DIR: string;
4
+ export declare const DEFAULT_EXCLUDE: string[];
@@ -0,0 +1,17 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export const DEFAULT_IDLE_MINUTES = 2;
4
+ export const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".hackhours", "config.json");
5
+ export const DEFAULT_DATA_DIR = path.join(os.homedir(), ".hackhours", "data");
6
+ export const DEFAULT_EXCLUDE = [
7
+ "**/node_modules/**",
8
+ "**/.git/**",
9
+ "**/dist/**",
10
+ "**/build/**",
11
+ "**/.next/**",
12
+ "**/.turbo/**",
13
+ "**/.cache/**",
14
+ "**/.idea/**",
15
+ "**/.vscode/**",
16
+ "**/*.log",
17
+ ];
@@ -0,0 +1,4 @@
1
+ export * from "./config/config.js";
2
+ export * from "./storage/chrono.js";
3
+ export * from "./tracker/watcher.js";
4
+ export * from "./analytics/queries.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./config/config.js";
2
+ export * from "./storage/chrono.js";
3
+ export * from "./tracker/watcher.js";
4
+ export * from "./analytics/queries.js";
@@ -0,0 +1,11 @@
1
+ import { AggregateDoc, EventDoc, SessionDoc } from "./schema.js";
2
+ export type ChronoCollections = {
3
+ sessions: any;
4
+ events: any;
5
+ aggregates: any;
6
+ };
7
+ export declare const openChrono: (dataDir: string) => Promise<{
8
+ db: any;
9
+ collections: ChronoCollections;
10
+ }>;
11
+ export type { AggregateDoc, EventDoc, SessionDoc };
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+ import ChronoDB from "chronodb";
3
+ import { aggregateSchema, eventSchema, sessionSchema, } from "./schema.js";
4
+ export const openChrono = async (dataDir) => {
5
+ fs.mkdirSync(dataDir, { recursive: true });
6
+ let db;
7
+ try {
8
+ db = await ChronoDB.open({ path: dataDir, cloudSync: false });
9
+ }
10
+ catch (error) {
11
+ const prev = process.cwd();
12
+ process.chdir(dataDir);
13
+ try {
14
+ db = await ChronoDB.open({ cloudSync: false });
15
+ }
16
+ catch (inner) {
17
+ process.chdir(prev);
18
+ throw inner;
19
+ }
20
+ }
21
+ const sessions = db.col("sessions", {
22
+ schema: sessionSchema,
23
+ indexes: ["sessionId", "startTimestamp", "endTimestamp"],
24
+ });
25
+ const events = db.col("events", {
26
+ schema: eventSchema,
27
+ indexes: ["timestamp", "language", "project", "sessionId"],
28
+ });
29
+ const aggregates = db.col("aggregates", {
30
+ schema: aggregateSchema,
31
+ indexes: ["date"],
32
+ });
33
+ return {
34
+ db,
35
+ collections: { sessions, events, aggregates },
36
+ };
37
+ };
@@ -0,0 +1,4 @@
1
+ import { ChronoCollections, EventDoc, SessionDoc } from "./chrono.js";
2
+ export declare const getSessionsInRange: (collections: ChronoCollections, from: number, to: number) => Promise<SessionDoc[]>;
3
+ export declare const getEventsInRange: (collections: ChronoCollections, from: number, to: number) => Promise<EventDoc[]>;
4
+ export declare const getAggregateByDate: (collections: ChronoCollections, date: string) => Promise<any>;
@@ -0,0 +1,14 @@
1
+ export const getSessionsInRange = async (collections, from, to) => {
2
+ const all = (await collections.sessions.getAll());
3
+ return all.filter((s) => {
4
+ const end = s.endTimestamp ?? s.startTimestamp;
5
+ return s.startTimestamp <= to && end >= from;
6
+ });
7
+ };
8
+ export const getEventsInRange = async (collections, from, to) => {
9
+ const all = (await collections.events.getAll());
10
+ return all.filter((e) => e.timestamp >= from && e.timestamp <= to);
11
+ };
12
+ export const getAggregateByDate = async (collections, date) => {
13
+ return collections.aggregates.getOne({ date });
14
+ };
@@ -0,0 +1,77 @@
1
+ export type SessionDoc = {
2
+ sessionId: string;
3
+ startTimestamp: number;
4
+ endTimestamp: number | null;
5
+ durationMs: number;
6
+ };
7
+ export type EventDoc = {
8
+ timestamp: number;
9
+ filePath: string;
10
+ language: string;
11
+ project: string;
12
+ sessionId: string;
13
+ };
14
+ export type AggregateDoc = {
15
+ date: string;
16
+ totalTimeMs: number;
17
+ filesEdited: string;
18
+ languagesUsed: string;
19
+ };
20
+ export declare const sessionSchema: {
21
+ sessionId: {
22
+ type: string;
23
+ distinct: boolean;
24
+ };
25
+ startTimestamp: {
26
+ type: string;
27
+ important: boolean;
28
+ };
29
+ endTimestamp: {
30
+ type: string;
31
+ nullable: boolean;
32
+ };
33
+ durationMs: {
34
+ type: string;
35
+ default: number;
36
+ };
37
+ };
38
+ export declare const eventSchema: {
39
+ timestamp: {
40
+ type: string;
41
+ important: boolean;
42
+ };
43
+ filePath: {
44
+ type: string;
45
+ important: boolean;
46
+ };
47
+ language: {
48
+ type: string;
49
+ important: boolean;
50
+ };
51
+ project: {
52
+ type: string;
53
+ important: boolean;
54
+ };
55
+ sessionId: {
56
+ type: string;
57
+ important: boolean;
58
+ };
59
+ };
60
+ export declare const aggregateSchema: {
61
+ date: {
62
+ type: string;
63
+ distinct: boolean;
64
+ };
65
+ totalTimeMs: {
66
+ type: string;
67
+ default: number;
68
+ };
69
+ filesEdited: {
70
+ type: string;
71
+ default: string;
72
+ };
73
+ languagesUsed: {
74
+ type: string;
75
+ default: string;
76
+ };
77
+ };
@@ -0,0 +1,19 @@
1
+ export const sessionSchema = {
2
+ sessionId: { type: "string", distinct: true },
3
+ startTimestamp: { type: "number", important: true },
4
+ endTimestamp: { type: "number", nullable: true },
5
+ durationMs: { type: "number", default: 0 },
6
+ };
7
+ export const eventSchema = {
8
+ timestamp: { type: "number", important: true },
9
+ filePath: { type: "string", important: true },
10
+ language: { type: "string", important: true },
11
+ project: { type: "string", important: true },
12
+ sessionId: { type: "string", important: true },
13
+ };
14
+ export const aggregateSchema = {
15
+ date: { type: "string", distinct: true },
16
+ totalTimeMs: { type: "number", default: 0 },
17
+ filesEdited: { type: "string", default: "[]" },
18
+ languagesUsed: { type: "string", default: "[]" },
19
+ };
@@ -0,0 +1,14 @@
1
+ import { HackHoursConfig } from "../config/config.js";
2
+ import { ChronoCollections } from "../storage/chrono.js";
3
+ export declare class SessionManager {
4
+ private active;
5
+ private idleMs;
6
+ private config;
7
+ private collections;
8
+ constructor(config: HackHoursConfig, collections: ChronoCollections);
9
+ handleActivity(filePath: string): Promise<void>;
10
+ checkIdle(): Promise<void>;
11
+ stop(): Promise<void>;
12
+ private startSession;
13
+ private endSession;
14
+ }
@@ -0,0 +1,78 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { detectLanguage } from "../utils/language.js";
4
+ import { resolveProjectRoot } from "../utils/project.js";
5
+ import { minutesToMs, toDateKey } from "../utils/time.js";
6
+ export class SessionManager {
7
+ constructor(config, collections) {
8
+ this.active = null;
9
+ this.config = config;
10
+ this.collections = collections;
11
+ this.idleMs = minutesToMs(config.idleMinutes);
12
+ }
13
+ async handleActivity(filePath) {
14
+ const now = Date.now();
15
+ if (!this.active) {
16
+ this.active = await this.startSession(now);
17
+ }
18
+ this.active.lastActivity = now;
19
+ const language = detectLanguage(filePath);
20
+ const projectRoot = resolveProjectRoot(filePath, this.config.directories);
21
+ await this.collections.events.add({
22
+ timestamp: now,
23
+ filePath: path.resolve(filePath),
24
+ language,
25
+ project: projectRoot,
26
+ sessionId: this.active.sessionId,
27
+ });
28
+ }
29
+ async checkIdle() {
30
+ if (!this.active)
31
+ return;
32
+ const now = Date.now();
33
+ if (now - this.active.lastActivity > this.idleMs) {
34
+ await this.endSession(this.active, this.active.lastActivity);
35
+ this.active = null;
36
+ }
37
+ }
38
+ async stop() {
39
+ if (!this.active)
40
+ return;
41
+ await this.endSession(this.active, this.active.lastActivity);
42
+ this.active = null;
43
+ }
44
+ async startSession(startTimestamp) {
45
+ const sessionId = crypto.randomUUID();
46
+ await this.collections.sessions.add({
47
+ sessionId,
48
+ startTimestamp,
49
+ endTimestamp: null,
50
+ durationMs: 0,
51
+ });
52
+ return {
53
+ sessionId,
54
+ startTimestamp,
55
+ lastActivity: startTimestamp,
56
+ };
57
+ }
58
+ async endSession(session, endTimestamp) {
59
+ const durationMs = Math.max(0, endTimestamp - session.startTimestamp);
60
+ await this.collections.sessions.updateMany({ sessionId: session.sessionId }, {
61
+ endTimestamp,
62
+ durationMs,
63
+ });
64
+ const dateKey = toDateKey(new Date(endTimestamp));
65
+ const existing = await this.collections.aggregates.getOne({ date: dateKey });
66
+ if (!existing) {
67
+ await this.collections.aggregates.add({
68
+ date: dateKey,
69
+ totalTimeMs: durationMs,
70
+ filesEdited: "[]",
71
+ languagesUsed: "[]",
72
+ });
73
+ }
74
+ else {
75
+ await this.collections.aggregates.updateMany({ date: dateKey }, { totalTimeMs: (existing.totalTimeMs ?? 0) + durationMs });
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,6 @@
1
+ import { HackHoursConfig } from "../config/config.js";
2
+ import { ChronoCollections } from "../storage/chrono.js";
3
+ export type WatcherHandle = {
4
+ stop: () => Promise<void>;
5
+ };
6
+ export declare const runWatcher: (config: HackHoursConfig, collections: ChronoCollections) => Promise<WatcherHandle>;
@@ -0,0 +1,25 @@
1
+ import chokidar from "chokidar";
2
+ import { SessionManager } from "./session.js";
3
+ export const runWatcher = async (config, collections) => {
4
+ const sessionManager = new SessionManager(config, collections);
5
+ const watcher = chokidar.watch(config.directories, {
6
+ ignored: config.exclude,
7
+ ignoreInitial: true,
8
+ persistent: true,
9
+ });
10
+ const onActivity = (filePath) => {
11
+ sessionManager.handleActivity(filePath).catch(() => undefined);
12
+ };
13
+ watcher.on("add", onActivity);
14
+ watcher.on("change", onActivity);
15
+ const interval = setInterval(() => {
16
+ sessionManager.checkIdle().catch(() => undefined);
17
+ }, 10000);
18
+ return {
19
+ stop: async () => {
20
+ clearInterval(interval);
21
+ await watcher.close();
22
+ await sessionManager.stop();
23
+ },
24
+ };
25
+ };
@@ -0,0 +1 @@
1
+ export declare const detectLanguage: (filePath: string) => string;
@@ -0,0 +1,42 @@
1
+ import path from "node:path";
2
+ const EXTENSION_MAP = {
3
+ ".ts": "TypeScript",
4
+ ".tsx": "TypeScript",
5
+ ".js": "JavaScript",
6
+ ".jsx": "JavaScript",
7
+ ".mjs": "JavaScript",
8
+ ".cjs": "JavaScript",
9
+ ".py": "Python",
10
+ ".go": "Go",
11
+ ".rs": "Rust",
12
+ ".java": "Java",
13
+ ".kt": "Kotlin",
14
+ ".c": "C",
15
+ ".h": "C",
16
+ ".cpp": "C++",
17
+ ".hpp": "C++",
18
+ ".cs": "C#",
19
+ ".rb": "Ruby",
20
+ ".php": "PHP",
21
+ ".swift": "Swift",
22
+ ".dart": "Dart",
23
+ ".lua": "Lua",
24
+ ".sh": "Shell",
25
+ ".bash": "Shell",
26
+ ".zsh": "Shell",
27
+ ".ps1": "PowerShell",
28
+ ".json": "JSON",
29
+ ".yml": "YAML",
30
+ ".yaml": "YAML",
31
+ ".toml": "TOML",
32
+ ".md": "Markdown",
33
+ ".html": "HTML",
34
+ ".css": "CSS",
35
+ ".scss": "SCSS",
36
+ ".less": "LESS",
37
+ ".sql": "SQL",
38
+ };
39
+ export const detectLanguage = (filePath) => {
40
+ const ext = path.extname(filePath).toLowerCase();
41
+ return EXTENSION_MAP[ext] ?? "Other";
42
+ };
@@ -0,0 +1,2 @@
1
+ export declare const resolveProjectRoot: (filePath: string, directories: string[]) => string;
2
+ export declare const formatProjectName: (projectRoot: string) => string;
@@ -0,0 +1,10 @@
1
+ import path from "node:path";
2
+ export const resolveProjectRoot = (filePath, directories) => {
3
+ const normalized = path.resolve(filePath);
4
+ const match = directories
5
+ .map((dir) => path.resolve(dir))
6
+ .filter((dir) => normalized.startsWith(dir))
7
+ .sort((a, b) => b.length - a.length)[0];
8
+ return match ?? path.dirname(normalized);
9
+ };
10
+ export const formatProjectName = (projectRoot) => path.basename(projectRoot);
@@ -0,0 +1,8 @@
1
+ export type DaemonState = {
2
+ pid: number;
3
+ startedAt: number;
4
+ };
5
+ export declare const statePath: () => string;
6
+ export declare const readState: () => DaemonState | null;
7
+ export declare const writeState: (state: DaemonState) => void;
8
+ export declare const clearState: () => void;
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const statePath = () => path.join(os.homedir(), ".hackhours", "state.json");
5
+ export const readState = () => {
6
+ const file = statePath();
7
+ if (!fs.existsSync(file))
8
+ return null;
9
+ const raw = fs.readFileSync(file, "utf-8");
10
+ return JSON.parse(raw);
11
+ };
12
+ export const writeState = (state) => {
13
+ const file = statePath();
14
+ const dir = path.dirname(file);
15
+ if (!fs.existsSync(dir)) {
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ }
18
+ fs.writeFileSync(file, JSON.stringify(state, null, 2), "utf-8");
19
+ };
20
+ export const clearState = () => {
21
+ const file = statePath();
22
+ if (fs.existsSync(file)) {
23
+ fs.unlinkSync(file);
24
+ }
25
+ };
@@ -0,0 +1,7 @@
1
+ export declare const minutesToMs: (minutes: number) => number;
2
+ export declare const startOfDay: (date: Date) => Date;
3
+ export declare const endOfDay: (date: Date) => Date;
4
+ export declare const toDateKey: (date: Date) => string;
5
+ export declare const parseDateKey: (value: string) => Date;
6
+ export declare const formatDuration: (ms: number) => string;
7
+ export declare const formatPercent: (value: number) => string;
@@ -0,0 +1,35 @@
1
+ export const minutesToMs = (minutes) => minutes * 60 * 1000;
2
+ export const startOfDay = (date) => {
3
+ const d = new Date(date);
4
+ d.setHours(0, 0, 0, 0);
5
+ return d;
6
+ };
7
+ export const endOfDay = (date) => {
8
+ const d = new Date(date);
9
+ d.setHours(23, 59, 59, 999);
10
+ return d;
11
+ };
12
+ export const toDateKey = (date) => {
13
+ const year = date.getFullYear();
14
+ const month = `${date.getMonth() + 1}`.padStart(2, "0");
15
+ const day = `${date.getDate()}`.padStart(2, "0");
16
+ return `${year}-${month}-${day}`;
17
+ };
18
+ export const parseDateKey = (value) => {
19
+ const [year, month, day] = value.split("-").map(Number);
20
+ if (!year || !month || !day) {
21
+ throw new Error(`Invalid date: ${value}`);
22
+ }
23
+ return new Date(year, month - 1, day);
24
+ };
25
+ export const formatDuration = (ms) => {
26
+ if (ms <= 0)
27
+ return "0m";
28
+ const totalMinutes = Math.round(ms / 60000);
29
+ const hours = Math.floor(totalMinutes / 60);
30
+ const minutes = totalMinutes % 60;
31
+ if (hours === 0)
32
+ return `${minutes}m`;
33
+ return `${hours}h ${minutes}m`;
34
+ };
35
+ export const formatPercent = (value) => `${(value * 100).toFixed(0)}%`;
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "hackhours",
3
+ "version": "0.1.0",
4
+ "description": "Offline local-first coding activity tracker",
5
+ "type": "module",
6
+ "bin": {
7
+ "hackhours": "dist/cli.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE",
15
+ "completions"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "dev": "tsx src/cli.ts",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest"
22
+ },
23
+ "keywords": [
24
+ "productivity",
25
+ "cli",
26
+ "tracking",
27
+ "offline"
28
+ ],
29
+ "author": "",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "asciichart": "^1.5.25",
33
+ "chalk": "^5.3.0",
34
+ "chokidar": "^3.6.0",
35
+ "chronodb": "^1.0.0",
36
+ "cli-table3": "^0.6.3",
37
+ "commander": "^11.1.0",
38
+ "inquirer": "^9.2.20"
39
+ },
40
+ "devDependencies": {
41
+ "@types/asciichart": "^1.5.8",
42
+ "@types/inquirer": "^9.0.9",
43
+ "@types/node": "^20.11.30",
44
+ "tsx": "^4.7.1",
45
+ "typescript": "^5.3.3",
46
+ "vitest": "^1.4.0"
47
+ }
48
+ }