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 +21 -0
- package/README.md +73 -0
- package/completions/_hackhours +34 -0
- package/completions/hackhours.bash +18 -0
- package/dist/analytics/queries.d.ts +10 -0
- package/dist/analytics/queries.js +56 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +274 -0
- package/dist/config/config.d.ts +11 -0
- package/dist/config/config.js +77 -0
- package/dist/config/defaults.d.ts +4 -0
- package/dist/config/defaults.js +17 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/storage/chrono.d.ts +11 -0
- package/dist/storage/chrono.js +37 -0
- package/dist/storage/queries.d.ts +4 -0
- package/dist/storage/queries.js +14 -0
- package/dist/storage/schema.d.ts +77 -0
- package/dist/storage/schema.js +19 -0
- package/dist/tracker/session.d.ts +14 -0
- package/dist/tracker/session.js +78 -0
- package/dist/tracker/watcher.d.ts +6 -0
- package/dist/tracker/watcher.js +25 -0
- package/dist/utils/language.d.ts +1 -0
- package/dist/utils/language.js +42 -0
- package/dist/utils/project.d.ts +2 -0
- package/dist/utils/project.js +10 -0
- package/dist/utils/state.d.ts +8 -0
- package/dist/utils/state.js +25 -0
- package/dist/utils/time.d.ts +7 -0
- package/dist/utils/time.js +35 -0
- package/package.json +48 -0
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
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,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
|
+
];
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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,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
|
+
}
|