hackhours 0.1.1 → 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/dist/analytics/queries.d.ts +11 -0
- package/dist/analytics/queries.js +54 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +332 -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 +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
activityByHourByLanguage: Map<string, number[]>;
|
|
9
|
+
activityByDay: Map<string, number>;
|
|
10
|
+
};
|
|
11
|
+
export declare const buildSummary: (collections: ChronoCollections, from: number, to: number, idleMinutes: number) => Promise<Summary>;
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
activityByHourByLanguage: new Map(),
|
|
26
|
+
activityByDay: new Map(),
|
|
27
|
+
};
|
|
28
|
+
for (const [sessionId, list] of eventsBySession.entries()) {
|
|
29
|
+
const session = sessionsById.get(sessionId);
|
|
30
|
+
const sessionEnd = session?.endTimestamp ?? now;
|
|
31
|
+
const sorted = [...list].sort((a, b) => a.timestamp - b.timestamp);
|
|
32
|
+
for (let i = 0; i < sorted.length; i += 1) {
|
|
33
|
+
const current = sorted[i];
|
|
34
|
+
const next = sorted[i + 1];
|
|
35
|
+
const nextTs = Math.min(next?.timestamp ?? sessionEnd, to);
|
|
36
|
+
const duration = Math.max(0, Math.min(nextTs - current.timestamp, idleMs));
|
|
37
|
+
if (duration <= 0)
|
|
38
|
+
continue;
|
|
39
|
+
summary.totalTimeMs += duration;
|
|
40
|
+
addToMap(summary.languages, current.language, duration);
|
|
41
|
+
addToMap(summary.projects, current.project, duration);
|
|
42
|
+
addToMap(summary.filesEdited, current.filePath, duration);
|
|
43
|
+
const eventDate = new Date(current.timestamp);
|
|
44
|
+
const hour = eventDate.getHours();
|
|
45
|
+
summary.activityByHour[hour] += duration;
|
|
46
|
+
if (!summary.activityByHourByLanguage.has(current.language)) {
|
|
47
|
+
summary.activityByHourByLanguage.set(current.language, Array.from({ length: 24 }, () => 0));
|
|
48
|
+
}
|
|
49
|
+
summary.activityByHourByLanguage.get(current.language)[hour] += duration;
|
|
50
|
+
addToMap(summary.activityByDay, toDateKey(eventDate), duration);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return summary;
|
|
54
|
+
};
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import Table from "cli-table3";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { loadConfig, promptInitConfig, saveConfig, } from "./config/config.js";
|
|
9
|
+
import { openChrono } from "./storage/chrono.js";
|
|
10
|
+
import { buildSummary } from "./analytics/queries.js";
|
|
11
|
+
import { runWatcher } from "./tracker/watcher.js";
|
|
12
|
+
import { readState, writeState, clearState } from "./utils/state.js";
|
|
13
|
+
import { endOfDay, formatDuration, parseDateKey, startOfDay } from "./utils/time.js";
|
|
14
|
+
const program = new Command();
|
|
15
|
+
const formatBar = (value, total, size = 16) => {
|
|
16
|
+
if (total <= 0)
|
|
17
|
+
return " ".repeat(size);
|
|
18
|
+
const filled = Math.round((value / total) * size);
|
|
19
|
+
return `${"█".repeat(filled)}${" ".repeat(size - filled)}`;
|
|
20
|
+
};
|
|
21
|
+
// Charts and history output removed per request.
|
|
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
|
+
// Activity chart removed per request.
|
|
39
|
+
};
|
|
40
|
+
const printBreakdown = (title, map, total) => {
|
|
41
|
+
console.log(chalk.bold(`\n${title}`));
|
|
42
|
+
const entries = [...map.entries()].sort((a, b) => b[1] - a[1]);
|
|
43
|
+
if (entries.length === 0) {
|
|
44
|
+
console.log("No activity recorded.");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const table = new Table({ head: ["Name", "Time", "%", ""] });
|
|
48
|
+
for (const [name, duration] of entries) {
|
|
49
|
+
const percent = total > 0 ? duration / total : 0;
|
|
50
|
+
table.push([name, formatDuration(duration), `${Math.round(percent * 100)}%`, formatBar(duration, total)]);
|
|
51
|
+
}
|
|
52
|
+
console.log(table.toString());
|
|
53
|
+
};
|
|
54
|
+
const topEntry = (map) => {
|
|
55
|
+
let best = null;
|
|
56
|
+
for (const entry of map.entries()) {
|
|
57
|
+
if (!best || entry[1] > best[1]) {
|
|
58
|
+
best = entry;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return best;
|
|
62
|
+
};
|
|
63
|
+
const parseRange = (from, to) => {
|
|
64
|
+
if (!from || !to) {
|
|
65
|
+
throw new Error("Both --from and --to are required.");
|
|
66
|
+
}
|
|
67
|
+
const fromDate = startOfDay(parseDateKey(from));
|
|
68
|
+
const toDate = endOfDay(parseDateKey(to));
|
|
69
|
+
return { from: fromDate.getTime(), to: toDate.getTime() };
|
|
70
|
+
};
|
|
71
|
+
program
|
|
72
|
+
.name("hackhours")
|
|
73
|
+
.description("Offline local-first coding activity tracker")
|
|
74
|
+
.version("0.1.0");
|
|
75
|
+
program
|
|
76
|
+
.command("init")
|
|
77
|
+
.description("Initialize HackHours configuration")
|
|
78
|
+
.action(async () => {
|
|
79
|
+
const config = await promptInitConfig();
|
|
80
|
+
saveConfig(config);
|
|
81
|
+
console.log(chalk.green("Saved configuration to ~/.hackhours/config.json"));
|
|
82
|
+
});
|
|
83
|
+
program
|
|
84
|
+
.command("start")
|
|
85
|
+
.description("Start background tracking")
|
|
86
|
+
.action(async () => {
|
|
87
|
+
const existing = readState();
|
|
88
|
+
if (existing) {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(existing.pid, 0);
|
|
91
|
+
console.log(chalk.yellow("HackHours is already running."));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
clearState();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const node = process.execPath;
|
|
99
|
+
const cli = path.resolve(process.argv[1]);
|
|
100
|
+
const child = spawn(node, [cli, "daemon"], {
|
|
101
|
+
detached: true,
|
|
102
|
+
stdio: "ignore",
|
|
103
|
+
});
|
|
104
|
+
child.unref();
|
|
105
|
+
writeState({ pid: child.pid ?? 0, startedAt: Date.now() });
|
|
106
|
+
console.log(chalk.green("HackHours tracking started."));
|
|
107
|
+
});
|
|
108
|
+
program
|
|
109
|
+
.command("stop")
|
|
110
|
+
.description("Stop background tracking")
|
|
111
|
+
.action(() => {
|
|
112
|
+
const state = readState();
|
|
113
|
+
if (!state) {
|
|
114
|
+
console.log(chalk.yellow("HackHours is not running."));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
process.kill(state.pid);
|
|
119
|
+
clearState();
|
|
120
|
+
console.log(chalk.green("HackHours tracking stopped."));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
clearState();
|
|
124
|
+
console.log(chalk.yellow("HackHours was not running."));
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
program
|
|
128
|
+
.command("status")
|
|
129
|
+
.description("Show tracker status and recent activity")
|
|
130
|
+
.action(async () => {
|
|
131
|
+
const state = readState();
|
|
132
|
+
let running = false;
|
|
133
|
+
if (state) {
|
|
134
|
+
try {
|
|
135
|
+
process.kill(state.pid, 0);
|
|
136
|
+
running = true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
clearState();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const config = loadConfig();
|
|
143
|
+
const { collections } = await openChrono(config.dataDir);
|
|
144
|
+
const now = new Date();
|
|
145
|
+
const todayFrom = startOfDay(now).getTime();
|
|
146
|
+
const todayTo = endOfDay(now).getTime();
|
|
147
|
+
const weekFromDate = new Date(now);
|
|
148
|
+
weekFromDate.setDate(weekFromDate.getDate() - 6);
|
|
149
|
+
const weekFrom = startOfDay(weekFromDate).getTime();
|
|
150
|
+
const weekTo = endOfDay(now).getTime();
|
|
151
|
+
const [todaySummary, weekSummary] = await Promise.all([
|
|
152
|
+
buildSummary(collections, todayFrom, todayTo, config.idleMinutes),
|
|
153
|
+
buildSummary(collections, weekFrom, weekTo, config.idleMinutes),
|
|
154
|
+
]);
|
|
155
|
+
console.log(chalk.bold("\nHackHours Status"));
|
|
156
|
+
console.log(`${chalk.cyan("Running:")} ${running ? chalk.green("Yes") : chalk.red("No")}`);
|
|
157
|
+
if (running && state) {
|
|
158
|
+
const startedAt = new Date(state.startedAt);
|
|
159
|
+
const uptime = Math.max(0, Date.now() - state.startedAt);
|
|
160
|
+
console.log(`${chalk.cyan("PID:")} ${state.pid}`);
|
|
161
|
+
console.log(`${chalk.cyan("Started:")} ${startedAt.toLocaleString()}`);
|
|
162
|
+
console.log(`${chalk.cyan("Uptime:")} ${formatDuration(uptime)}`);
|
|
163
|
+
}
|
|
164
|
+
console.log(`\n${chalk.cyan("Today:")} ${formatDuration(todaySummary.totalTimeMs)}`);
|
|
165
|
+
const todayTopProject = topEntry(todaySummary.projects);
|
|
166
|
+
if (todayTopProject) {
|
|
167
|
+
console.log(`${chalk.cyan("Top project (today):")} ${todayTopProject[0]} (${formatDuration(todayTopProject[1])})`);
|
|
168
|
+
}
|
|
169
|
+
const todayTopLang = topEntry(todaySummary.languages);
|
|
170
|
+
if (todayTopLang) {
|
|
171
|
+
console.log(`${chalk.cyan("Top language (today):")} ${todayTopLang[0]} (${formatDuration(todayTopLang[1])})`);
|
|
172
|
+
}
|
|
173
|
+
console.log(`\n${chalk.cyan("Last 7 days:")} ${formatDuration(weekSummary.totalTimeMs)}`);
|
|
174
|
+
const weekTopProject = topEntry(weekSummary.projects);
|
|
175
|
+
if (weekTopProject) {
|
|
176
|
+
console.log(`${chalk.cyan("Top project (7 days):")} ${weekTopProject[0]} (${formatDuration(weekTopProject[1])})`);
|
|
177
|
+
}
|
|
178
|
+
const weekTopLang = topEntry(weekSummary.languages);
|
|
179
|
+
if (weekTopLang) {
|
|
180
|
+
console.log(`${chalk.cyan("Top language (7 days):")} ${weekTopLang[0]} (${formatDuration(weekTopLang[1])})`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
program
|
|
184
|
+
.command("daemon")
|
|
185
|
+
.description("Run watcher in foreground (internal)")
|
|
186
|
+
.action(async () => {
|
|
187
|
+
const config = loadConfig();
|
|
188
|
+
const { collections } = await openChrono(config.dataDir);
|
|
189
|
+
const watcher = await runWatcher(config, collections);
|
|
190
|
+
const shutdown = async () => {
|
|
191
|
+
await watcher.stop();
|
|
192
|
+
process.exit(0);
|
|
193
|
+
};
|
|
194
|
+
process.on("SIGINT", shutdown);
|
|
195
|
+
process.on("SIGTERM", shutdown);
|
|
196
|
+
});
|
|
197
|
+
program
|
|
198
|
+
.command("today")
|
|
199
|
+
.description("Show today's summary")
|
|
200
|
+
.action(async () => {
|
|
201
|
+
const config = loadConfig();
|
|
202
|
+
const { collections } = await openChrono(config.dataDir);
|
|
203
|
+
const now = new Date();
|
|
204
|
+
const from = startOfDay(now).getTime();
|
|
205
|
+
const to = endOfDay(now).getTime();
|
|
206
|
+
const summary = await buildSummary(collections, from, to, config.idleMinutes);
|
|
207
|
+
printSummary("HackHours – Today", summary);
|
|
208
|
+
});
|
|
209
|
+
program
|
|
210
|
+
.command("week")
|
|
211
|
+
.description("Show weekly summary")
|
|
212
|
+
.action(async () => {
|
|
213
|
+
const config = loadConfig();
|
|
214
|
+
const { collections } = await openChrono(config.dataDir);
|
|
215
|
+
const now = new Date();
|
|
216
|
+
const fromDate = new Date(now);
|
|
217
|
+
fromDate.setDate(fromDate.getDate() - 6);
|
|
218
|
+
const from = startOfDay(fromDate).getTime();
|
|
219
|
+
const to = endOfDay(now).getTime();
|
|
220
|
+
const summary = await buildSummary(collections, from, to, config.idleMinutes);
|
|
221
|
+
printSummary("HackHours – Last 7 Days", summary);
|
|
222
|
+
});
|
|
223
|
+
program
|
|
224
|
+
.command("month")
|
|
225
|
+
.description("Show monthly summary")
|
|
226
|
+
.action(async () => {
|
|
227
|
+
const config = loadConfig();
|
|
228
|
+
const { collections } = await openChrono(config.dataDir);
|
|
229
|
+
const now = new Date();
|
|
230
|
+
const fromDate = new Date(now);
|
|
231
|
+
fromDate.setDate(fromDate.getDate() - 29);
|
|
232
|
+
const from = startOfDay(fromDate).getTime();
|
|
233
|
+
const to = endOfDay(now).getTime();
|
|
234
|
+
const summary = await buildSummary(collections, from, to, config.idleMinutes);
|
|
235
|
+
printSummary("HackHours – Last 30 Days", summary);
|
|
236
|
+
});
|
|
237
|
+
program
|
|
238
|
+
.command("stats")
|
|
239
|
+
.description("Custom stats for a given range")
|
|
240
|
+
.requiredOption("--from <date>", "Start date (YYYY-MM-DD)")
|
|
241
|
+
.requiredOption("--to <date>", "End date (YYYY-MM-DD)")
|
|
242
|
+
.action(async (options) => {
|
|
243
|
+
const config = loadConfig();
|
|
244
|
+
const { collections } = await openChrono(config.dataDir);
|
|
245
|
+
const range = parseRange(options.from, options.to);
|
|
246
|
+
const summary = await buildSummary(collections, range.from, range.to, config.idleMinutes);
|
|
247
|
+
printSummary(`HackHours – ${options.from} to ${options.to}`, summary);
|
|
248
|
+
});
|
|
249
|
+
program
|
|
250
|
+
.command("languages")
|
|
251
|
+
.description("Language breakdown")
|
|
252
|
+
.option("--from <date>", "Start date (YYYY-MM-DD)")
|
|
253
|
+
.option("--to <date>", "End date (YYYY-MM-DD)")
|
|
254
|
+
.action(async (options) => {
|
|
255
|
+
const config = loadConfig();
|
|
256
|
+
const { collections } = await openChrono(config.dataDir);
|
|
257
|
+
let from;
|
|
258
|
+
let to;
|
|
259
|
+
if (options.from && options.to) {
|
|
260
|
+
const range = parseRange(options.from, options.to);
|
|
261
|
+
from = range.from;
|
|
262
|
+
to = range.to;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const now = new Date();
|
|
266
|
+
const fromDate = new Date(now);
|
|
267
|
+
fromDate.setDate(fromDate.getDate() - 6);
|
|
268
|
+
from = startOfDay(fromDate).getTime();
|
|
269
|
+
to = endOfDay(now).getTime();
|
|
270
|
+
}
|
|
271
|
+
const summary = await buildSummary(collections, from, to, config.idleMinutes);
|
|
272
|
+
printBreakdown("Languages", summary.languages, summary.totalTimeMs);
|
|
273
|
+
});
|
|
274
|
+
program
|
|
275
|
+
.command("projects")
|
|
276
|
+
.description("Project breakdown")
|
|
277
|
+
.option("--from <date>", "Start date (YYYY-MM-DD)")
|
|
278
|
+
.option("--to <date>", "End date (YYYY-MM-DD)")
|
|
279
|
+
.action(async (options) => {
|
|
280
|
+
const config = loadConfig();
|
|
281
|
+
const { collections } = await openChrono(config.dataDir);
|
|
282
|
+
let from;
|
|
283
|
+
let to;
|
|
284
|
+
if (options.from && options.to) {
|
|
285
|
+
const range = parseRange(options.from, options.to);
|
|
286
|
+
from = range.from;
|
|
287
|
+
to = range.to;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
const now = new Date();
|
|
291
|
+
const fromDate = new Date(now);
|
|
292
|
+
fromDate.setDate(fromDate.getDate() - 6);
|
|
293
|
+
from = startOfDay(fromDate).getTime();
|
|
294
|
+
to = endOfDay(now).getTime();
|
|
295
|
+
}
|
|
296
|
+
const summary = await buildSummary(collections, from, to, config.idleMinutes);
|
|
297
|
+
printBreakdown("Projects", summary.projects, summary.totalTimeMs);
|
|
298
|
+
});
|
|
299
|
+
program
|
|
300
|
+
.command("files")
|
|
301
|
+
.description("File breakdown (top 10)")
|
|
302
|
+
.option("--from <date>", "Start date (YYYY-MM-DD)")
|
|
303
|
+
.option("--to <date>", "End date (YYYY-MM-DD)")
|
|
304
|
+
.action(async (options) => {
|
|
305
|
+
const config = loadConfig();
|
|
306
|
+
const { collections } = await openChrono(config.dataDir);
|
|
307
|
+
let from;
|
|
308
|
+
let to;
|
|
309
|
+
if (options.from && options.to) {
|
|
310
|
+
const range = parseRange(options.from, options.to);
|
|
311
|
+
from = range.from;
|
|
312
|
+
to = range.to;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
const now = new Date();
|
|
316
|
+
const fromDate = new Date(now);
|
|
317
|
+
fromDate.setDate(fromDate.getDate() - 6);
|
|
318
|
+
from = startOfDay(fromDate).getTime();
|
|
319
|
+
to = endOfDay(now).getTime();
|
|
320
|
+
}
|
|
321
|
+
const summary = await buildSummary(collections, from, to, config.idleMinutes);
|
|
322
|
+
const top = [...summary.filesEdited.entries()]
|
|
323
|
+
.sort((a, b) => b[1] - a[1])
|
|
324
|
+
.slice(0, 10);
|
|
325
|
+
const total = summary.totalTimeMs;
|
|
326
|
+
const table = new Table({ head: ["File", "Time", "%"] });
|
|
327
|
+
for (const [file, duration] of top) {
|
|
328
|
+
table.push([file, formatDuration(duration), `${Math.round((duration / total) * 100)}%`]);
|
|
329
|
+
}
|
|
330
|
+
console.log(table.toString());
|
|
331
|
+
});
|
|
332
|
+
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)}%`;
|