@vibecoded/work 0.0.14 → 0.0.15
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/README.md +27 -5
- package/dist/cli.js +344 -313
- package/dist/lib.js +277 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -16,14 +16,14 @@ Minimal local time-tracking CLI (Bun + TypeScript) that stores everything on dis
|
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
19
|
-
```
|
|
19
|
+
```bash
|
|
20
20
|
bun add -g @vibecoded/work
|
|
21
21
|
npm install -g @vibecoded/work
|
|
22
22
|
````
|
|
23
23
|
|
|
24
24
|
## Quick start
|
|
25
25
|
|
|
26
|
-
```
|
|
26
|
+
```bash
|
|
27
27
|
work init
|
|
28
28
|
work on "Client X" "Kickoff call"
|
|
29
29
|
work status
|
|
@@ -35,13 +35,35 @@ work undo
|
|
|
35
35
|
work off
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Library usage
|
|
39
|
+
|
|
40
|
+
Import the Node API and call the same lower-level helpers used by the CLI.
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { ensureWorkHome, getWorkHome, loadProjects } from '@vibecoded/work';
|
|
44
|
+
|
|
45
|
+
const workHome = getWorkHome();
|
|
46
|
+
await ensureWorkHome(workHome);
|
|
47
|
+
|
|
48
|
+
const projects = await loadProjects(workHome);
|
|
49
|
+
console.log(projects.projects);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
You can also import the explicit library entry:
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { fmtElapsed } from '@vibecoded/work/lib';
|
|
56
|
+
|
|
57
|
+
console.log(fmtElapsed(90_000));
|
|
58
|
+
```
|
|
59
|
+
|
|
38
60
|
## Data location
|
|
39
61
|
|
|
40
62
|
Default: `~/.work`
|
|
41
63
|
|
|
42
64
|
Override with:
|
|
43
65
|
|
|
44
|
-
```
|
|
66
|
+
```bash
|
|
45
67
|
work --dir ./my-work-data status
|
|
46
68
|
```
|
|
47
69
|
|
|
@@ -145,9 +167,9 @@ Each project is stored in `data/<projectId>/` with:
|
|
|
145
167
|
|
|
146
168
|
## Build
|
|
147
169
|
|
|
148
|
-
Build the CLI
|
|
170
|
+
Build the CLI and library bundles:
|
|
149
171
|
|
|
150
|
-
```
|
|
172
|
+
```bash
|
|
151
173
|
bun run build
|
|
152
174
|
```
|
|
153
175
|
|
package/dist/cli.js
CHANGED
|
@@ -2221,8 +2221,345 @@ async function saveTaskIndex(workHome, projectId, idx) {
|
|
|
2221
2221
|
await saveJson(getTaskIndexPath(workHome, projectId), idx);
|
|
2222
2222
|
}
|
|
2223
2223
|
|
|
2224
|
-
// src/
|
|
2224
|
+
// src/lib/services/format.ts
|
|
2225
|
+
function formatWorkLabel(projectName, task) {
|
|
2226
|
+
return `${projectName}${task ? " • " + task : ""}`;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// src/lib/services/history.ts
|
|
2225
2230
|
import { readdir } from "node:fs/promises";
|
|
2231
|
+
async function findLatestSession(projects, workHome) {
|
|
2232
|
+
let latest = null;
|
|
2233
|
+
for (const project of projects.projects) {
|
|
2234
|
+
const projectDir = getProjectDir(workHome, project.id);
|
|
2235
|
+
let entries = [];
|
|
2236
|
+
try {
|
|
2237
|
+
entries = await readdir(projectDir);
|
|
2238
|
+
} catch {
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
const csvFiles = entries.filter((entry) => /^\d{4}-\d{2}\.csv$/.test(entry));
|
|
2242
|
+
for (const file of csvFiles) {
|
|
2243
|
+
const csvPath = pathJoin(projectDir, file);
|
|
2244
|
+
const rows = await readCsvRows(csvPath);
|
|
2245
|
+
for (let i = 0;i < rows.length; i++) {
|
|
2246
|
+
const row = rows[i];
|
|
2247
|
+
if (!row || row.deletedAt)
|
|
2248
|
+
continue;
|
|
2249
|
+
const timeMs = Date.parse(row.endAt) || Date.parse(row.startAt) || 0;
|
|
2250
|
+
if (!latest || timeMs > latest.timeMs) {
|
|
2251
|
+
latest = { project, csvPath, rowIndex: i, rows, timeMs };
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return latest;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// src/lib/services/dates.ts
|
|
2260
|
+
function localYearMonth(d) {
|
|
2261
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
|
|
2262
|
+
}
|
|
2263
|
+
function localDateKey(d) {
|
|
2264
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
2265
|
+
}
|
|
2266
|
+
function parsePartialDateTime(input) {
|
|
2267
|
+
const trimmed = input.trim();
|
|
2268
|
+
if (!trimmed)
|
|
2269
|
+
return null;
|
|
2270
|
+
const [datePart, timePartRaw] = trimmed.split(/[T ]/);
|
|
2271
|
+
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datePart);
|
|
2272
|
+
if (!dateMatch)
|
|
2273
|
+
return null;
|
|
2274
|
+
const year = Number(dateMatch[1]);
|
|
2275
|
+
const month = Number(dateMatch[2]);
|
|
2276
|
+
const day = Number(dateMatch[3]);
|
|
2277
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
2278
|
+
return null;
|
|
2279
|
+
let hour = 0;
|
|
2280
|
+
let minute = randomInt(0, 59);
|
|
2281
|
+
let second = randomInt(0, 59);
|
|
2282
|
+
let ms = randomInt(0, 999);
|
|
2283
|
+
if (timePartRaw) {
|
|
2284
|
+
const timeParts = timePartRaw.split(":");
|
|
2285
|
+
if (timeParts[0])
|
|
2286
|
+
hour = Number(timeParts[0]);
|
|
2287
|
+
if (!Number.isFinite(hour))
|
|
2288
|
+
return null;
|
|
2289
|
+
if (timeParts[1] != null) {
|
|
2290
|
+
minute = fillPartialNumber(timeParts[1], 0, 59);
|
|
2291
|
+
}
|
|
2292
|
+
if (timeParts[2] != null) {
|
|
2293
|
+
const [secPart, msPart] = timeParts[2].split(".");
|
|
2294
|
+
second = fillPartialNumber(secPart, 0, 59);
|
|
2295
|
+
if (msPart != null) {
|
|
2296
|
+
ms = fillPartialMs(msPart);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
if (timeParts[1] == null) {
|
|
2300
|
+
minute = randomInt(0, 59);
|
|
2301
|
+
second = randomInt(0, 59);
|
|
2302
|
+
ms = randomInt(0, 999);
|
|
2303
|
+
} else if (timeParts[2] == null) {
|
|
2304
|
+
second = randomInt(0, 59);
|
|
2305
|
+
ms = randomInt(0, 999);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
if (hour < 0 || hour > 23)
|
|
2309
|
+
return null;
|
|
2310
|
+
if (minute < 0 || minute > 59)
|
|
2311
|
+
return null;
|
|
2312
|
+
if (second < 0 || second > 59)
|
|
2313
|
+
return null;
|
|
2314
|
+
if (ms < 0 || ms > 999)
|
|
2315
|
+
return null;
|
|
2316
|
+
return new Date(year, month - 1, day, hour, minute, second, ms);
|
|
2317
|
+
}
|
|
2318
|
+
function pad2(n) {
|
|
2319
|
+
return String(n).padStart(2, "0");
|
|
2320
|
+
}
|
|
2321
|
+
function fillPartialNumber(part, min, max) {
|
|
2322
|
+
const trimmed = part.trim();
|
|
2323
|
+
if (!trimmed)
|
|
2324
|
+
return randomInt(min, max);
|
|
2325
|
+
if (/^\d$/.test(trimmed)) {
|
|
2326
|
+
const tens = Number(trimmed) * 10;
|
|
2327
|
+
return clamp(tens + randomInt(0, 9), min, max);
|
|
2328
|
+
}
|
|
2329
|
+
const value = Number(trimmed);
|
|
2330
|
+
if (!Number.isFinite(value))
|
|
2331
|
+
return randomInt(min, max);
|
|
2332
|
+
return clamp(value, min, max);
|
|
2333
|
+
}
|
|
2334
|
+
function fillPartialMs(part) {
|
|
2335
|
+
const trimmed = part.trim();
|
|
2336
|
+
if (!trimmed)
|
|
2337
|
+
return randomInt(0, 999);
|
|
2338
|
+
if (/^\d{1,3}$/.test(trimmed)) {
|
|
2339
|
+
const value = Number(trimmed.padEnd(3, String(randomInt(0, 9))));
|
|
2340
|
+
return clamp(value, 0, 999);
|
|
2341
|
+
}
|
|
2342
|
+
return randomInt(0, 999);
|
|
2343
|
+
}
|
|
2344
|
+
function randomInt(min, max) {
|
|
2345
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
2346
|
+
}
|
|
2347
|
+
function clamp(value, min, max) {
|
|
2348
|
+
return Math.min(max, Math.max(min, value));
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// src/lib/services/info.ts
|
|
2352
|
+
function normalizeTaskLabel(task) {
|
|
2353
|
+
return (task ?? "").trim() || "(no task)";
|
|
2354
|
+
}
|
|
2355
|
+
function fmtHours(ms) {
|
|
2356
|
+
const totalMin = Math.round(ms / 60000);
|
|
2357
|
+
const h = Math.floor(totalMin / 60);
|
|
2358
|
+
const m = totalMin % 60;
|
|
2359
|
+
if (h === 0)
|
|
2360
|
+
return `${m}m`;
|
|
2361
|
+
if (m === 0)
|
|
2362
|
+
return `${h}h`;
|
|
2363
|
+
return `${h}h ${String(m).padStart(2, "0")}m`;
|
|
2364
|
+
}
|
|
2365
|
+
function buildInfoSummary(rows) {
|
|
2366
|
+
const active = rows.filter((r) => !r.deletedAt);
|
|
2367
|
+
const byDayMs = new Map;
|
|
2368
|
+
const byTaskMs = new Map;
|
|
2369
|
+
let totalMs = 0;
|
|
2370
|
+
for (const r of active) {
|
|
2371
|
+
const start = Date.parse(r.startAt);
|
|
2372
|
+
const end = Date.parse(r.endAt);
|
|
2373
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
|
|
2374
|
+
continue;
|
|
2375
|
+
const ms = end - start;
|
|
2376
|
+
totalMs += ms;
|
|
2377
|
+
const dayKey = localDateKey(new Date(start));
|
|
2378
|
+
byDayMs.set(dayKey, (byDayMs.get(dayKey) ?? 0) + ms);
|
|
2379
|
+
const taskKey = normalizeTaskLabel(r.task);
|
|
2380
|
+
byTaskMs.set(taskKey, (byTaskMs.get(taskKey) ?? 0) + ms);
|
|
2381
|
+
}
|
|
2382
|
+
const dayRows = sortMapByKey(byDayMs).map(([k, ms]) => ({
|
|
2383
|
+
key: k,
|
|
2384
|
+
ms,
|
|
2385
|
+
pretty: fmtHours(ms)
|
|
2386
|
+
}));
|
|
2387
|
+
const taskRows = sortMapByValueDesc(byTaskMs).map(([k, ms]) => ({
|
|
2388
|
+
key: k,
|
|
2389
|
+
ms,
|
|
2390
|
+
pretty: fmtHours(ms)
|
|
2391
|
+
}));
|
|
2392
|
+
return { totalMs, dayRows, taskRows };
|
|
2393
|
+
}
|
|
2394
|
+
function buildInfoExportCsv(projectName, month, totalMs, dayRows, taskRows) {
|
|
2395
|
+
const lines = [];
|
|
2396
|
+
lines.push(`meta,project,${csvEsc(projectName)}`);
|
|
2397
|
+
lines.push(`meta,month,${csvEsc(month)}`);
|
|
2398
|
+
lines.push(`meta,total,${csvEsc(fmtHours(totalMs))}`);
|
|
2399
|
+
lines.push("");
|
|
2400
|
+
lines.push("type,key,durationMinutes,duration");
|
|
2401
|
+
for (const r of dayRows) {
|
|
2402
|
+
lines.push(`day,${csvEsc(r.key)},${Math.round(r.ms / 60000)},${csvEsc(r.pretty)}`);
|
|
2403
|
+
}
|
|
2404
|
+
for (const r of taskRows) {
|
|
2405
|
+
lines.push(`task,${csvEsc(r.key)},${Math.round(r.ms / 60000)},${csvEsc(r.pretty)}`);
|
|
2406
|
+
}
|
|
2407
|
+
return lines.join(`
|
|
2408
|
+
`) + `
|
|
2409
|
+
`;
|
|
2410
|
+
}
|
|
2411
|
+
function sortMapByKey(m) {
|
|
2412
|
+
return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
2413
|
+
}
|
|
2414
|
+
function sortMapByValueDesc(m) {
|
|
2415
|
+
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
|
2416
|
+
}
|
|
2417
|
+
function csvEsc(v) {
|
|
2418
|
+
if (/[,"\n\r]/.test(v))
|
|
2419
|
+
return `"${v.replace(/"/g, '""')}"`;
|
|
2420
|
+
return v;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// src/lib/services/ids.ts
|
|
2424
|
+
function createId() {
|
|
2425
|
+
return globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : randomUuidFallback();
|
|
2426
|
+
}
|
|
2427
|
+
function randomUuidFallback() {
|
|
2428
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
|
|
2429
|
+
const r = Math.random() * 16 | 0;
|
|
2430
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
2431
|
+
return v.toString(16);
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// src/lib/services/projects.ts
|
|
2436
|
+
function findProjectByNameOrId(projects, arg) {
|
|
2437
|
+
const a = arg.trim().toLowerCase();
|
|
2438
|
+
return projects.projects.find((p) => p.id.toLowerCase() === a) ?? projects.projects.find((p) => p.name.toLowerCase() === a) ?? null;
|
|
2439
|
+
}
|
|
2440
|
+
async function resolveProjectByNameOrCreate(workHome, projects, name) {
|
|
2441
|
+
const n = name.trim();
|
|
2442
|
+
const found = projects.projects.find((p) => p.name.toLowerCase() === n.toLowerCase());
|
|
2443
|
+
if (found)
|
|
2444
|
+
return found;
|
|
2445
|
+
const created = {
|
|
2446
|
+
id: createId(),
|
|
2447
|
+
name: n,
|
|
2448
|
+
createdAt: nowIso()
|
|
2449
|
+
};
|
|
2450
|
+
projects.projects.push(created);
|
|
2451
|
+
await saveProjects(workHome, projects);
|
|
2452
|
+
return created;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// src/lib/services/tasks.ts
|
|
2456
|
+
function sortTasksByUsage(a, b) {
|
|
2457
|
+
const ca = a.count ?? 0;
|
|
2458
|
+
const cb = b.count ?? 0;
|
|
2459
|
+
if (cb !== ca)
|
|
2460
|
+
return cb - ca;
|
|
2461
|
+
const la = a.lastUsedAt ? Date.parse(a.lastUsedAt) : 0;
|
|
2462
|
+
const lb = b.lastUsedAt ? Date.parse(b.lastUsedAt) : 0;
|
|
2463
|
+
if (lb !== la)
|
|
2464
|
+
return lb - la;
|
|
2465
|
+
return a.name.localeCompare(b.name);
|
|
2466
|
+
}
|
|
2467
|
+
function sortTasksByRecentUse(a, b) {
|
|
2468
|
+
const la = a.lastUsedAt ? Date.parse(a.lastUsedAt) : 0;
|
|
2469
|
+
const lb = b.lastUsedAt ? Date.parse(b.lastUsedAt) : 0;
|
|
2470
|
+
if (lb !== la)
|
|
2471
|
+
return lb - la;
|
|
2472
|
+
return a.name.localeCompare(b.name);
|
|
2473
|
+
}
|
|
2474
|
+
function updateTaskStats(idx, taskName) {
|
|
2475
|
+
const trimmed = taskName.trim();
|
|
2476
|
+
if (!trimmed)
|
|
2477
|
+
return;
|
|
2478
|
+
const entry = idx.tasks.find((t) => t.name.toLowerCase() === trimmed.toLowerCase());
|
|
2479
|
+
if (entry) {
|
|
2480
|
+
entry.count = (entry.count ?? 0) + 1;
|
|
2481
|
+
entry.lastUsedAt = nowIso();
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
idx.tasks.push({
|
|
2485
|
+
name: trimmed,
|
|
2486
|
+
count: 1,
|
|
2487
|
+
lastUsedAt: nowIso(),
|
|
2488
|
+
createdAt: nowIso()
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
async function resolveTaskByNameOrCreate(workHome, projectId, task) {
|
|
2492
|
+
const idx = await loadTaskIndex(workHome, projectId);
|
|
2493
|
+
const normalized = task.trim();
|
|
2494
|
+
const found = idx.tasks.find((x) => x.name.toLowerCase() === normalized.toLowerCase());
|
|
2495
|
+
if (found)
|
|
2496
|
+
return found.name;
|
|
2497
|
+
idx.tasks.push({ name: normalized, count: 0, lastUsedAt: null, createdAt: nowIso() });
|
|
2498
|
+
await saveTaskIndex(workHome, projectId, idx);
|
|
2499
|
+
return normalized;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// src/lib/services/sessions.ts
|
|
2503
|
+
function createSession(projectId, projectName, task) {
|
|
2504
|
+
return {
|
|
2505
|
+
id: createId(),
|
|
2506
|
+
projectId,
|
|
2507
|
+
projectName,
|
|
2508
|
+
task,
|
|
2509
|
+
startAt: nowIso(),
|
|
2510
|
+
pausedAt: null,
|
|
2511
|
+
pausedMs: 0,
|
|
2512
|
+
note: ""
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
function sessionElapsedMs(session) {
|
|
2516
|
+
const endAt = session.pausedAt ? Date.parse(session.pausedAt) : Date.now();
|
|
2517
|
+
return endAt - Date.parse(session.startAt) - (session.pausedMs ?? 0);
|
|
2518
|
+
}
|
|
2519
|
+
async function stopAndPersist(workHome, s, endAtIso) {
|
|
2520
|
+
const projectDir = getProjectDir(workHome, s.projectId);
|
|
2521
|
+
await ensureDir(projectDir);
|
|
2522
|
+
const idx = await loadTaskIndex(workHome, s.projectId);
|
|
2523
|
+
updateTaskStats(idx, s.task);
|
|
2524
|
+
await saveTaskIndex(workHome, s.projectId, idx);
|
|
2525
|
+
const endAt = new Date(Date.parse(endAtIso));
|
|
2526
|
+
const ym = localYearMonth(endAt);
|
|
2527
|
+
const csvPath = getMonthCsvPath(workHome, s.projectId, ym);
|
|
2528
|
+
await ensureCsvHeader(csvPath);
|
|
2529
|
+
const row = {
|
|
2530
|
+
id: s.id,
|
|
2531
|
+
startAt: s.startAt,
|
|
2532
|
+
endAt: endAtIso,
|
|
2533
|
+
task: s.task,
|
|
2534
|
+
note: s.note ?? "",
|
|
2535
|
+
createdAt: s.startAt,
|
|
2536
|
+
updatedAt: endAtIso,
|
|
2537
|
+
deletedAt: ""
|
|
2538
|
+
};
|
|
2539
|
+
await appendCsvRow(csvPath, row);
|
|
2540
|
+
}
|
|
2541
|
+
async function addManualEntry(workHome, projectId, task, startAt, endAt, note) {
|
|
2542
|
+
const idx = await loadTaskIndex(workHome, projectId);
|
|
2543
|
+
updateTaskStats(idx, task);
|
|
2544
|
+
await saveTaskIndex(workHome, projectId, idx);
|
|
2545
|
+
const ym = localYearMonth(new Date(Date.parse(endAt)));
|
|
2546
|
+
const csvPath = getMonthCsvPath(workHome, projectId, ym);
|
|
2547
|
+
await ensureCsvHeader(csvPath);
|
|
2548
|
+
const now = nowIso();
|
|
2549
|
+
const row = {
|
|
2550
|
+
id: createId(),
|
|
2551
|
+
startAt,
|
|
2552
|
+
endAt,
|
|
2553
|
+
task,
|
|
2554
|
+
note,
|
|
2555
|
+
createdAt: now,
|
|
2556
|
+
updatedAt: now,
|
|
2557
|
+
deletedAt: ""
|
|
2558
|
+
};
|
|
2559
|
+
await appendCsvRow(csvPath, row);
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// src/cli.ts
|
|
2226
2563
|
function usage() {
|
|
2227
2564
|
console.log(`
|
|
2228
2565
|
work - minimal time tracking (monthly CSV per project)
|
|
@@ -2376,16 +2713,7 @@ async function cmdOn(workHome, rest, flags) {
|
|
|
2376
2713
|
await stopAndPersist(workHome, cur, nowIso());
|
|
2377
2714
|
running.session = null;
|
|
2378
2715
|
}
|
|
2379
|
-
const session =
|
|
2380
|
-
id: globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : randomUuidFallback(),
|
|
2381
|
-
projectId: project.id,
|
|
2382
|
-
projectName: project.name,
|
|
2383
|
-
task,
|
|
2384
|
-
startAt: nowIso(),
|
|
2385
|
-
pausedAt: null,
|
|
2386
|
-
pausedMs: 0,
|
|
2387
|
-
note: ""
|
|
2388
|
-
};
|
|
2716
|
+
const session = createSession(project.id, project.name, task);
|
|
2389
2717
|
await saveRunning(workHome, { session });
|
|
2390
2718
|
console.log(`Working on ${formatWorkLabel(project.name, task)}`);
|
|
2391
2719
|
}
|
|
@@ -2436,24 +2764,8 @@ async function cmdAdd(workHome, rest, flags) {
|
|
|
2436
2764
|
const projects = await loadProjects(workHome);
|
|
2437
2765
|
const project = await resolveProjectByNameOrCreate(workHome, projects, projectName);
|
|
2438
2766
|
const task = await resolveTaskByNameOrCreate(workHome, project.id, taskName);
|
|
2439
|
-
const
|
|
2440
|
-
|
|
2441
|
-
await saveTaskIndex(workHome, project.id, idx);
|
|
2442
|
-
const ym = localYearMonth(new Date(endMs));
|
|
2443
|
-
const csvPath = getMonthCsvPath(workHome, project.id, ym);
|
|
2444
|
-
await ensureCsvHeader(csvPath);
|
|
2445
|
-
const now = nowIso();
|
|
2446
|
-
const row = {
|
|
2447
|
-
id: globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : randomUuidFallback(),
|
|
2448
|
-
startAt,
|
|
2449
|
-
endAt,
|
|
2450
|
-
task,
|
|
2451
|
-
note: typeof flags.note === "string" ? flags.note : "",
|
|
2452
|
-
createdAt: now,
|
|
2453
|
-
updatedAt: now,
|
|
2454
|
-
deletedAt: ""
|
|
2455
|
-
};
|
|
2456
|
-
await appendCsvRow(csvPath, row);
|
|
2767
|
+
const note = typeof flags.note === "string" ? flags.note : "";
|
|
2768
|
+
await addManualEntry(workHome, project.id, task, startAt, endAt, note);
|
|
2457
2769
|
console.log(`Added: ${formatWorkLabel(project.name, task)}`);
|
|
2458
2770
|
}
|
|
2459
2771
|
async function cmdPause(workHome) {
|
|
@@ -2533,34 +2845,9 @@ async function cmdInfo(workHome, rest, flags) {
|
|
|
2533
2845
|
process.exit(1);
|
|
2534
2846
|
}
|
|
2535
2847
|
const projectDir = getProjectDir(workHome, project.id);
|
|
2536
|
-
const csvPath = getMonthCsvPath(workHome, project.id, month);
|
|
2537
|
-
const rows = await readCsvRows(csvPath);
|
|
2538
|
-
const
|
|
2539
|
-
const byDayMs = new Map;
|
|
2540
|
-
const byTaskMs = new Map;
|
|
2541
|
-
let totalMs = 0;
|
|
2542
|
-
for (const r of active) {
|
|
2543
|
-
const start = Date.parse(r.startAt);
|
|
2544
|
-
const end = Date.parse(r.endAt);
|
|
2545
|
-
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
|
|
2546
|
-
continue;
|
|
2547
|
-
const ms = end - start;
|
|
2548
|
-
totalMs += ms;
|
|
2549
|
-
const dayKey = localDateKey(new Date(start));
|
|
2550
|
-
byDayMs.set(dayKey, (byDayMs.get(dayKey) ?? 0) + ms);
|
|
2551
|
-
const taskKey = normalizeTaskLabel(r.task);
|
|
2552
|
-
byTaskMs.set(taskKey, (byTaskMs.get(taskKey) ?? 0) + ms);
|
|
2553
|
-
}
|
|
2554
|
-
const dayRows = sortMapByKey(byDayMs).map(([k, ms]) => ({
|
|
2555
|
-
key: k,
|
|
2556
|
-
ms,
|
|
2557
|
-
pretty: fmtHours(ms)
|
|
2558
|
-
}));
|
|
2559
|
-
const taskRows = sortMapByValueDesc(byTaskMs).map(([k, ms]) => ({
|
|
2560
|
-
key: k,
|
|
2561
|
-
ms,
|
|
2562
|
-
pretty: fmtHours(ms)
|
|
2563
|
-
}));
|
|
2848
|
+
const csvPath = getMonthCsvPath(workHome, project.id, month);
|
|
2849
|
+
const rows = await readCsvRows(csvPath);
|
|
2850
|
+
const { totalMs, dayRows, taskRows } = buildInfoSummary(rows);
|
|
2564
2851
|
console.log(`
|
|
2565
2852
|
Project: ${project.name}`);
|
|
2566
2853
|
console.log(`Month: ${month}`);
|
|
@@ -2580,10 +2867,6 @@ By task`);
|
|
|
2580
2867
|
Exported: ${outPath}`);
|
|
2581
2868
|
}
|
|
2582
2869
|
}
|
|
2583
|
-
function findProjectByNameOrId(projects, arg) {
|
|
2584
|
-
const a = arg.trim().toLowerCase();
|
|
2585
|
-
return projects.projects.find((p) => p.id.toLowerCase() === a) ?? projects.projects.find((p) => p.name.toLowerCase() === a) ?? null;
|
|
2586
|
-
}
|
|
2587
2870
|
async function pickProjectOnly(projects) {
|
|
2588
2871
|
if (!projects.projects.length) {
|
|
2589
2872
|
console.error("No projects found. Use: work on (create one)");
|
|
@@ -2598,20 +2881,6 @@ async function pickProjectOnly(projects) {
|
|
|
2598
2881
|
});
|
|
2599
2882
|
return projects.projects.find((p) => p.id === picked) ?? null;
|
|
2600
2883
|
}
|
|
2601
|
-
async function resolveProjectByNameOrCreate(workHome, projects, name) {
|
|
2602
|
-
const n = name.trim();
|
|
2603
|
-
const found = projects.projects.find((p) => p.name.toLowerCase() === n.toLowerCase());
|
|
2604
|
-
if (found)
|
|
2605
|
-
return found;
|
|
2606
|
-
const created = {
|
|
2607
|
-
id: globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : randomUuidFallback(),
|
|
2608
|
-
name: n,
|
|
2609
|
-
createdAt: nowIso()
|
|
2610
|
-
};
|
|
2611
|
-
projects.projects.push(created);
|
|
2612
|
-
await saveProjects(workHome, projects);
|
|
2613
|
-
return created;
|
|
2614
|
-
}
|
|
2615
2884
|
async function pickProjectOrCreate(workHome, projects) {
|
|
2616
2885
|
const picked = await dist_default4({
|
|
2617
2886
|
message: "Select a project",
|
|
@@ -2637,16 +2906,6 @@ async function pickProjectOrCreate(workHome, projects) {
|
|
|
2637
2906
|
throw new Error("Project selection failed");
|
|
2638
2907
|
return found;
|
|
2639
2908
|
}
|
|
2640
|
-
async function resolveTaskByNameOrCreate(workHome, projectId, task) {
|
|
2641
|
-
const idx = await loadTaskIndex(workHome, projectId);
|
|
2642
|
-
const normalized = task.trim();
|
|
2643
|
-
const found = idx.tasks.find((x) => x.name.toLowerCase() === normalized.toLowerCase());
|
|
2644
|
-
if (found)
|
|
2645
|
-
return found.name;
|
|
2646
|
-
idx.tasks.push({ name: normalized, count: 0, lastUsedAt: null, createdAt: nowIso() });
|
|
2647
|
-
await saveTaskIndex(workHome, projectId, idx);
|
|
2648
|
-
return normalized;
|
|
2649
|
-
}
|
|
2650
2909
|
async function pickTaskOrCreate(workHome, projectId, projectName) {
|
|
2651
2910
|
const idx = await loadTaskIndex(workHome, projectId);
|
|
2652
2911
|
const picked = await dist_default4({
|
|
@@ -2667,53 +2926,6 @@ async function pickTaskOrCreate(workHome, projectId, projectName) {
|
|
|
2667
2926
|
}
|
|
2668
2927
|
return picked;
|
|
2669
2928
|
}
|
|
2670
|
-
async function stopAndPersist(workHome, s, endAtIso) {
|
|
2671
|
-
const projectDir = getProjectDir(workHome, s.projectId);
|
|
2672
|
-
await ensureDir(projectDir);
|
|
2673
|
-
const idx = await loadTaskIndex(workHome, s.projectId);
|
|
2674
|
-
updateTaskStats(idx, s.task);
|
|
2675
|
-
await saveTaskIndex(workHome, s.projectId, idx);
|
|
2676
|
-
const endAt = new Date(Date.parse(endAtIso));
|
|
2677
|
-
const ym = localYearMonth(endAt);
|
|
2678
|
-
const csvPath = getMonthCsvPath(workHome, s.projectId, ym);
|
|
2679
|
-
await ensureCsvHeader(csvPath);
|
|
2680
|
-
const row = {
|
|
2681
|
-
id: s.id,
|
|
2682
|
-
startAt: s.startAt,
|
|
2683
|
-
endAt: endAtIso,
|
|
2684
|
-
task: s.task,
|
|
2685
|
-
note: s.note ?? "",
|
|
2686
|
-
createdAt: s.startAt,
|
|
2687
|
-
updatedAt: endAtIso,
|
|
2688
|
-
deletedAt: ""
|
|
2689
|
-
};
|
|
2690
|
-
await appendCsvRow(csvPath, row);
|
|
2691
|
-
}
|
|
2692
|
-
function pad2(n) {
|
|
2693
|
-
return String(n).padStart(2, "0");
|
|
2694
|
-
}
|
|
2695
|
-
function localYearMonth(d) {
|
|
2696
|
-
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
|
|
2697
|
-
}
|
|
2698
|
-
function localDateKey(d) {
|
|
2699
|
-
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
2700
|
-
}
|
|
2701
|
-
function fmtHours(ms) {
|
|
2702
|
-
const totalMin = Math.round(ms / 60000);
|
|
2703
|
-
const h = Math.floor(totalMin / 60);
|
|
2704
|
-
const m = totalMin % 60;
|
|
2705
|
-
if (h === 0)
|
|
2706
|
-
return `${m}m`;
|
|
2707
|
-
if (m === 0)
|
|
2708
|
-
return `${h}h`;
|
|
2709
|
-
return `${h}h ${String(m).padStart(2, "0")}m`;
|
|
2710
|
-
}
|
|
2711
|
-
function sortMapByKey(m) {
|
|
2712
|
-
return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
2713
|
-
}
|
|
2714
|
-
function sortMapByValueDesc(m) {
|
|
2715
|
-
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
|
2716
|
-
}
|
|
2717
2929
|
function printTable(headers, rows) {
|
|
2718
2930
|
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
2719
2931
|
const line = (cols) => cols.map((c, i) => (c ?? "").padEnd(widths[i] || 0)).join(" ").trimEnd();
|
|
@@ -2722,38 +2934,6 @@ function printTable(headers, rows) {
|
|
|
2722
2934
|
for (const r of rows)
|
|
2723
2935
|
console.log(line(r));
|
|
2724
2936
|
}
|
|
2725
|
-
function buildInfoExportCsv(projectName, month, totalMs, dayRows, taskRows) {
|
|
2726
|
-
const lines = [];
|
|
2727
|
-
lines.push(`meta,project,${csvEsc(projectName)}`);
|
|
2728
|
-
lines.push(`meta,month,${csvEsc(month)}`);
|
|
2729
|
-
lines.push(`meta,total,${csvEsc(fmtHours(totalMs))}`);
|
|
2730
|
-
lines.push("");
|
|
2731
|
-
lines.push("type,key,durationMinutes,duration");
|
|
2732
|
-
for (const r of dayRows) {
|
|
2733
|
-
lines.push(`day,${csvEsc(r.key)},${Math.round(r.ms / 60000)},${csvEsc(r.pretty)}`);
|
|
2734
|
-
}
|
|
2735
|
-
for (const r of taskRows) {
|
|
2736
|
-
lines.push(`task,${csvEsc(r.key)},${Math.round(r.ms / 60000)},${csvEsc(r.pretty)}`);
|
|
2737
|
-
}
|
|
2738
|
-
return lines.join(`
|
|
2739
|
-
`) + `
|
|
2740
|
-
`;
|
|
2741
|
-
}
|
|
2742
|
-
function csvEsc(v) {
|
|
2743
|
-
if (/[,"\n\r]/.test(v))
|
|
2744
|
-
return `"${v.replace(/"/g, '""')}"`;
|
|
2745
|
-
return v;
|
|
2746
|
-
}
|
|
2747
|
-
function formatWorkLabel(projectName, task) {
|
|
2748
|
-
return `${projectName}${task ? " • " + task : ""}`;
|
|
2749
|
-
}
|
|
2750
|
-
function normalizeTaskLabel(task) {
|
|
2751
|
-
return (task ?? "").trim() || "(no task)";
|
|
2752
|
-
}
|
|
2753
|
-
function sessionElapsedMs(session) {
|
|
2754
|
-
const endAt = session.pausedAt ? Date.parse(session.pausedAt) : Date.now();
|
|
2755
|
-
return endAt - Date.parse(session.startAt) - (session.pausedMs ?? 0);
|
|
2756
|
-
}
|
|
2757
2937
|
function parseInfoArgs(args) {
|
|
2758
2938
|
const a1 = args[0]?.trim();
|
|
2759
2939
|
const a2 = args[1]?.trim();
|
|
@@ -2783,149 +2963,6 @@ function parseDateArg(value, label) {
|
|
|
2783
2963
|
}
|
|
2784
2964
|
return parsed.toISOString();
|
|
2785
2965
|
}
|
|
2786
|
-
function parsePartialDateTime(input) {
|
|
2787
|
-
const trimmed = input.trim();
|
|
2788
|
-
if (!trimmed)
|
|
2789
|
-
return null;
|
|
2790
|
-
const [datePart, timePartRaw] = trimmed.split(/[T ]/);
|
|
2791
|
-
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datePart);
|
|
2792
|
-
if (!dateMatch)
|
|
2793
|
-
return null;
|
|
2794
|
-
const year = Number(dateMatch[1]);
|
|
2795
|
-
const month = Number(dateMatch[2]);
|
|
2796
|
-
const day = Number(dateMatch[3]);
|
|
2797
|
-
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
2798
|
-
return null;
|
|
2799
|
-
let hour = 0;
|
|
2800
|
-
let minute = randomInt(0, 59);
|
|
2801
|
-
let second = randomInt(0, 59);
|
|
2802
|
-
let ms = randomInt(0, 999);
|
|
2803
|
-
if (timePartRaw) {
|
|
2804
|
-
const timeParts = timePartRaw.split(":");
|
|
2805
|
-
if (timeParts[0])
|
|
2806
|
-
hour = Number(timeParts[0]);
|
|
2807
|
-
if (!Number.isFinite(hour))
|
|
2808
|
-
return null;
|
|
2809
|
-
if (timeParts[1] != null) {
|
|
2810
|
-
minute = fillPartialNumber(timeParts[1], 0, 59);
|
|
2811
|
-
}
|
|
2812
|
-
if (timeParts[2] != null) {
|
|
2813
|
-
const [secPart, msPart] = timeParts[2].split(".");
|
|
2814
|
-
second = fillPartialNumber(secPart, 0, 59);
|
|
2815
|
-
if (msPart != null) {
|
|
2816
|
-
ms = fillPartialMs(msPart);
|
|
2817
|
-
}
|
|
2818
|
-
}
|
|
2819
|
-
if (timeParts[1] == null) {
|
|
2820
|
-
minute = randomInt(0, 59);
|
|
2821
|
-
second = randomInt(0, 59);
|
|
2822
|
-
ms = randomInt(0, 999);
|
|
2823
|
-
} else if (timeParts[2] == null) {
|
|
2824
|
-
second = randomInt(0, 59);
|
|
2825
|
-
ms = randomInt(0, 999);
|
|
2826
|
-
}
|
|
2827
|
-
}
|
|
2828
|
-
if (hour < 0 || hour > 23)
|
|
2829
|
-
return null;
|
|
2830
|
-
if (minute < 0 || minute > 59)
|
|
2831
|
-
return null;
|
|
2832
|
-
if (second < 0 || second > 59)
|
|
2833
|
-
return null;
|
|
2834
|
-
if (ms < 0 || ms > 999)
|
|
2835
|
-
return null;
|
|
2836
|
-
return new Date(year, month - 1, day, hour, minute, second, ms);
|
|
2837
|
-
}
|
|
2838
|
-
function fillPartialNumber(part, min, max) {
|
|
2839
|
-
const trimmed = part.trim();
|
|
2840
|
-
if (!trimmed)
|
|
2841
|
-
return randomInt(min, max);
|
|
2842
|
-
if (/^\d$/.test(trimmed)) {
|
|
2843
|
-
const tens = Number(trimmed) * 10;
|
|
2844
|
-
return clamp(tens + randomInt(0, 9), min, max);
|
|
2845
|
-
}
|
|
2846
|
-
const value = Number(trimmed);
|
|
2847
|
-
if (!Number.isFinite(value))
|
|
2848
|
-
return randomInt(min, max);
|
|
2849
|
-
return clamp(value, min, max);
|
|
2850
|
-
}
|
|
2851
|
-
function fillPartialMs(part) {
|
|
2852
|
-
const trimmed = part.trim();
|
|
2853
|
-
if (!trimmed)
|
|
2854
|
-
return randomInt(0, 999);
|
|
2855
|
-
if (/^\d{1,3}$/.test(trimmed)) {
|
|
2856
|
-
const value = Number(trimmed.padEnd(3, String(randomInt(0, 9))));
|
|
2857
|
-
return clamp(value, 0, 999);
|
|
2858
|
-
}
|
|
2859
|
-
return randomInt(0, 999);
|
|
2860
|
-
}
|
|
2861
|
-
function randomInt(min, max) {
|
|
2862
|
-
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
2863
|
-
}
|
|
2864
|
-
function clamp(value, min, max) {
|
|
2865
|
-
return Math.min(max, Math.max(min, value));
|
|
2866
|
-
}
|
|
2867
|
-
function sortTasksByUsage(a, b) {
|
|
2868
|
-
const ca = a.count ?? 0;
|
|
2869
|
-
const cb = b.count ?? 0;
|
|
2870
|
-
if (cb !== ca)
|
|
2871
|
-
return cb - ca;
|
|
2872
|
-
const la = a.lastUsedAt ? Date.parse(a.lastUsedAt) : 0;
|
|
2873
|
-
const lb = b.lastUsedAt ? Date.parse(b.lastUsedAt) : 0;
|
|
2874
|
-
if (lb !== la)
|
|
2875
|
-
return lb - la;
|
|
2876
|
-
return a.name.localeCompare(b.name);
|
|
2877
|
-
}
|
|
2878
|
-
function sortTasksByRecentUse(a, b) {
|
|
2879
|
-
const la = a.lastUsedAt ? Date.parse(a.lastUsedAt) : 0;
|
|
2880
|
-
const lb = b.lastUsedAt ? Date.parse(b.lastUsedAt) : 0;
|
|
2881
|
-
if (lb !== la)
|
|
2882
|
-
return lb - la;
|
|
2883
|
-
return a.name.localeCompare(b.name);
|
|
2884
|
-
}
|
|
2885
|
-
function updateTaskStats(idx, taskName) {
|
|
2886
|
-
const trimmed = taskName.trim();
|
|
2887
|
-
if (!trimmed)
|
|
2888
|
-
return;
|
|
2889
|
-
const entry = idx.tasks.find((t) => t.name.toLowerCase() === trimmed.toLowerCase());
|
|
2890
|
-
if (entry) {
|
|
2891
|
-
entry.count = (entry.count ?? 0) + 1;
|
|
2892
|
-
entry.lastUsedAt = nowIso();
|
|
2893
|
-
return;
|
|
2894
|
-
}
|
|
2895
|
-
idx.tasks.push({
|
|
2896
|
-
name: trimmed,
|
|
2897
|
-
count: 1,
|
|
2898
|
-
lastUsedAt: nowIso(),
|
|
2899
|
-
createdAt: nowIso()
|
|
2900
|
-
});
|
|
2901
|
-
}
|
|
2902
|
-
async function findLatestSession(projects, workHome) {
|
|
2903
|
-
let latest = null;
|
|
2904
|
-
for (const project of projects.projects) {
|
|
2905
|
-
const projectDir = getProjectDir(workHome, project.id);
|
|
2906
|
-
let entries = [];
|
|
2907
|
-
try {
|
|
2908
|
-
entries = await readdir(projectDir);
|
|
2909
|
-
} catch {
|
|
2910
|
-
continue;
|
|
2911
|
-
}
|
|
2912
|
-
const csvFiles = entries.filter((entry) => /^\d{4}-\d{2}\.csv$/.test(entry));
|
|
2913
|
-
for (const file of csvFiles) {
|
|
2914
|
-
const csvPath = pathJoin(projectDir, file);
|
|
2915
|
-
const rows = await readCsvRows(csvPath);
|
|
2916
|
-
for (let i = 0;i < rows.length; i++) {
|
|
2917
|
-
const row = rows[i];
|
|
2918
|
-
if (!row || row.deletedAt)
|
|
2919
|
-
continue;
|
|
2920
|
-
const timeMs = Date.parse(row.endAt) || Date.parse(row.startAt) || 0;
|
|
2921
|
-
if (!latest || timeMs > latest.timeMs) {
|
|
2922
|
-
latest = { project, csvPath, rowIndex: i, rows, timeMs };
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
}
|
|
2926
|
-
}
|
|
2927
|
-
return latest;
|
|
2928
|
-
}
|
|
2929
2966
|
process.on("uncaughtException", (error) => {
|
|
2930
2967
|
if (error && typeof error === "object" && error.name === "ExitPromptError") {
|
|
2931
2968
|
console.log("\uD83D\uDC4B until next time!");
|
|
@@ -2933,10 +2970,4 @@ process.on("uncaughtException", (error) => {
|
|
|
2933
2970
|
throw error;
|
|
2934
2971
|
}
|
|
2935
2972
|
});
|
|
2936
|
-
function randomUuidFallback() {
|
|
2937
|
-
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
|
|
2938
|
-
const r = Math.random() * 16 | 0, v = c === "x" ? r : r & 3 | 8;
|
|
2939
|
-
return v.toString(16);
|
|
2940
|
-
});
|
|
2941
|
-
}
|
|
2942
2973
|
await main();
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// src/lib/csv.ts
|
|
2
|
+
import { appendFile, readFile, writeFile, access } from "node:fs/promises";
|
|
3
|
+
import { constants as FS } from "node:fs";
|
|
4
|
+
var HEADER = `id,startAt,endAt,task,note,createdAt,updatedAt,deletedAt
|
|
5
|
+
`;
|
|
6
|
+
function esc(v) {
|
|
7
|
+
const s = (v ?? "").toString();
|
|
8
|
+
if (/[,"\n\r]/.test(s))
|
|
9
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
10
|
+
return s;
|
|
11
|
+
}
|
|
12
|
+
async function exists(path) {
|
|
13
|
+
try {
|
|
14
|
+
await access(path, FS.F_OK);
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function ensureCsvHeader(path) {
|
|
21
|
+
if (!await exists(path)) {
|
|
22
|
+
await writeFile(path, HEADER, "utf-8");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function appendCsvRow(path, row) {
|
|
26
|
+
const line = serializeCsvRows([row], false);
|
|
27
|
+
await appendFile(path, line, "utf-8");
|
|
28
|
+
}
|
|
29
|
+
function unquoteCsvCell(s) {
|
|
30
|
+
if (s.startsWith('"') && s.endsWith('"')) {
|
|
31
|
+
return s.slice(1, -1).replace(/""/g, '"');
|
|
32
|
+
}
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
function splitCsvLine(line) {
|
|
36
|
+
const out = [];
|
|
37
|
+
let cur = "";
|
|
38
|
+
let inQ = false;
|
|
39
|
+
for (let i = 0;i < line.length; i++) {
|
|
40
|
+
const ch = line[i];
|
|
41
|
+
if (ch === '"') {
|
|
42
|
+
const next = line[i + 1];
|
|
43
|
+
if (inQ && next === '"') {
|
|
44
|
+
cur += '"';
|
|
45
|
+
i++;
|
|
46
|
+
} else {
|
|
47
|
+
inQ = !inQ;
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === "," && !inQ) {
|
|
52
|
+
out.push(cur);
|
|
53
|
+
cur = "";
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
cur += ch;
|
|
57
|
+
}
|
|
58
|
+
out.push(cur);
|
|
59
|
+
return out.map(unquoteCsvCell);
|
|
60
|
+
}
|
|
61
|
+
async function readCsvRows(path) {
|
|
62
|
+
if (!await exists(path))
|
|
63
|
+
return [];
|
|
64
|
+
const txt = await readFile(path, "utf-8");
|
|
65
|
+
const lines = txt.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
66
|
+
if (lines.length <= 1)
|
|
67
|
+
return [];
|
|
68
|
+
const header = lines[0]?.trim();
|
|
69
|
+
if (header !== HEADER.trim()) {
|
|
70
|
+
throw new Error(`Unexpected CSV header in ${path}`);
|
|
71
|
+
}
|
|
72
|
+
const rows = [];
|
|
73
|
+
for (let i = 1;i < lines.length; i++) {
|
|
74
|
+
const cols = splitCsvLine(lines[i] || "");
|
|
75
|
+
if (cols.length < 8)
|
|
76
|
+
continue;
|
|
77
|
+
rows.push({
|
|
78
|
+
id: cols[0] ?? "",
|
|
79
|
+
startAt: cols[1] ?? "",
|
|
80
|
+
endAt: cols[2] ?? "",
|
|
81
|
+
task: cols[3] ?? "",
|
|
82
|
+
note: cols[4] ?? "",
|
|
83
|
+
createdAt: cols[5] ?? "",
|
|
84
|
+
updatedAt: cols[6] ?? "",
|
|
85
|
+
deletedAt: cols[7] ?? ""
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return rows;
|
|
89
|
+
}
|
|
90
|
+
function serializeCsvRows(rows, includeHeader = true) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
if (includeHeader)
|
|
93
|
+
lines.push(HEADER.trimEnd());
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
lines.push([
|
|
96
|
+
row.id,
|
|
97
|
+
row.startAt,
|
|
98
|
+
row.endAt,
|
|
99
|
+
row.task,
|
|
100
|
+
row.note,
|
|
101
|
+
row.createdAt,
|
|
102
|
+
row.updatedAt,
|
|
103
|
+
row.deletedAt
|
|
104
|
+
].map(esc).join(","));
|
|
105
|
+
}
|
|
106
|
+
return lines.join(`
|
|
107
|
+
`) + `
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
async function writeCsvRows(path, rows) {
|
|
111
|
+
await writeFile(path, serializeCsvRows(rows, true), "utf-8");
|
|
112
|
+
}
|
|
113
|
+
// src/lib/fs.ts
|
|
114
|
+
import { mkdir } from "node:fs/promises";
|
|
115
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
116
|
+
import { homedir } from "node:os";
|
|
117
|
+
function pathJoin(...parts) {
|
|
118
|
+
const cleaned = parts.filter(Boolean).map((p, i) => {
|
|
119
|
+
let s = String(p).trim();
|
|
120
|
+
if (i === 0)
|
|
121
|
+
s = s.replace(/\/+$/g, "");
|
|
122
|
+
else
|
|
123
|
+
s = s.replace(/^\/+/g, "").replace(/\/+$/g, "");
|
|
124
|
+
return s;
|
|
125
|
+
}).filter((p) => p.length > 0);
|
|
126
|
+
if (cleaned.length === 0)
|
|
127
|
+
return "";
|
|
128
|
+
const joined = cleaned.join("/");
|
|
129
|
+
const first = parts.find((p) => String(p ?? "").trim().length > 0) ?? "";
|
|
130
|
+
const isAbs = String(first).trim().startsWith("/");
|
|
131
|
+
return isAbs ? joined.startsWith("/") ? joined : `/${joined}` : joined;
|
|
132
|
+
}
|
|
133
|
+
function expandHome(p) {
|
|
134
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir() || "";
|
|
135
|
+
if (!home)
|
|
136
|
+
return p;
|
|
137
|
+
if (p === "~")
|
|
138
|
+
return home;
|
|
139
|
+
if (p.startsWith("~/"))
|
|
140
|
+
return `${home}/${p.slice(2)}`;
|
|
141
|
+
return p;
|
|
142
|
+
}
|
|
143
|
+
function getWorkHome(dirFlag) {
|
|
144
|
+
if (typeof dirFlag === "string" && dirFlag.trim())
|
|
145
|
+
return expandHome(dirFlag.trim());
|
|
146
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir() || ".";
|
|
147
|
+
return pathJoin(home, ".work");
|
|
148
|
+
}
|
|
149
|
+
async function ensureDir(dir) {
|
|
150
|
+
await mkdir(dir, { recursive: true });
|
|
151
|
+
}
|
|
152
|
+
async function ensureWorkHome(workHome) {
|
|
153
|
+
await ensureDir(workHome);
|
|
154
|
+
await ensureDir(pathJoin(workHome, "data"));
|
|
155
|
+
}
|
|
156
|
+
async function loadJson(path, fallback) {
|
|
157
|
+
try {
|
|
158
|
+
const txt = await readFile2(path, "utf8");
|
|
159
|
+
return JSON.parse(txt);
|
|
160
|
+
} catch {
|
|
161
|
+
return fallback;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function saveJson(path, data) {
|
|
165
|
+
const txt = JSON.stringify(data, null, 2) + `
|
|
166
|
+
`;
|
|
167
|
+
await writeFile2(path, txt, "utf8");
|
|
168
|
+
}
|
|
169
|
+
async function writeTextFile(path, content) {
|
|
170
|
+
await writeFile2(path, content, "utf8");
|
|
171
|
+
}
|
|
172
|
+
function nowIso() {
|
|
173
|
+
return new Date().toISOString();
|
|
174
|
+
}
|
|
175
|
+
function slugify(s) {
|
|
176
|
+
return s.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "project";
|
|
177
|
+
}
|
|
178
|
+
function parseArgs(argv) {
|
|
179
|
+
const flags = {};
|
|
180
|
+
const positional = [];
|
|
181
|
+
for (let i = 0;i < argv.length; i++) {
|
|
182
|
+
const a = String(argv[i] ?? "");
|
|
183
|
+
if (a.startsWith("--")) {
|
|
184
|
+
const key = a.slice(2);
|
|
185
|
+
const next = argv[i + 1];
|
|
186
|
+
if (!next || String(next).startsWith("-")) {
|
|
187
|
+
flags[key] = true;
|
|
188
|
+
} else {
|
|
189
|
+
flags[key] = String(next);
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
positional.push(a);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const cmd = positional[0];
|
|
197
|
+
const rest = positional.slice(1);
|
|
198
|
+
return { cmd, rest, flags };
|
|
199
|
+
}
|
|
200
|
+
// src/lib/store.ts
|
|
201
|
+
var PROJECTS_FILE = "projects.json";
|
|
202
|
+
var RUNNING_FILE = "running.json";
|
|
203
|
+
var TASKS_FILE = "tasks.json";
|
|
204
|
+
function getProjectsPath(workHome) {
|
|
205
|
+
return pathJoin(workHome, PROJECTS_FILE);
|
|
206
|
+
}
|
|
207
|
+
function getRunningPath(workHome) {
|
|
208
|
+
return pathJoin(workHome, RUNNING_FILE);
|
|
209
|
+
}
|
|
210
|
+
function getProjectDir(workHome, projectId) {
|
|
211
|
+
return pathJoin(workHome, "data", projectId);
|
|
212
|
+
}
|
|
213
|
+
function getTaskIndexPath(workHome, projectId) {
|
|
214
|
+
return pathJoin(getProjectDir(workHome, projectId), TASKS_FILE);
|
|
215
|
+
}
|
|
216
|
+
function getMonthCsvPath(workHome, projectId, yearMonth) {
|
|
217
|
+
return pathJoin(getProjectDir(workHome, projectId), `${yearMonth}.csv`);
|
|
218
|
+
}
|
|
219
|
+
async function loadProjects(workHome) {
|
|
220
|
+
return loadJson(getProjectsPath(workHome), { projects: [] });
|
|
221
|
+
}
|
|
222
|
+
async function saveProjects(workHome, projects) {
|
|
223
|
+
await saveJson(getProjectsPath(workHome), projects);
|
|
224
|
+
}
|
|
225
|
+
async function loadRunning(workHome) {
|
|
226
|
+
return loadJson(getRunningPath(workHome), { session: null });
|
|
227
|
+
}
|
|
228
|
+
async function saveRunning(workHome, running) {
|
|
229
|
+
await saveJson(getRunningPath(workHome), running);
|
|
230
|
+
}
|
|
231
|
+
async function loadTaskIndex(workHome, projectId) {
|
|
232
|
+
await ensureDir(getProjectDir(workHome, projectId));
|
|
233
|
+
return loadJson(getTaskIndexPath(workHome, projectId), { tasks: [] });
|
|
234
|
+
}
|
|
235
|
+
async function saveTaskIndex(workHome, projectId, idx) {
|
|
236
|
+
await ensureDir(getProjectDir(workHome, projectId));
|
|
237
|
+
await saveJson(getTaskIndexPath(workHome, projectId), idx);
|
|
238
|
+
}
|
|
239
|
+
// src/lib/time.ts
|
|
240
|
+
function fmtElapsed(ms) {
|
|
241
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
242
|
+
const h = Math.floor(total / 3600);
|
|
243
|
+
const m = Math.floor(total % 3600 / 60);
|
|
244
|
+
const s = total % 60;
|
|
245
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
246
|
+
}
|
|
247
|
+
export {
|
|
248
|
+
writeTextFile,
|
|
249
|
+
writeCsvRows,
|
|
250
|
+
slugify,
|
|
251
|
+
serializeCsvRows,
|
|
252
|
+
saveTaskIndex,
|
|
253
|
+
saveRunning,
|
|
254
|
+
saveProjects,
|
|
255
|
+
saveJson,
|
|
256
|
+
readCsvRows,
|
|
257
|
+
pathJoin,
|
|
258
|
+
parseArgs,
|
|
259
|
+
nowIso,
|
|
260
|
+
loadTaskIndex,
|
|
261
|
+
loadRunning,
|
|
262
|
+
loadProjects,
|
|
263
|
+
loadJson,
|
|
264
|
+
getWorkHome,
|
|
265
|
+
getTaskIndexPath,
|
|
266
|
+
getRunningPath,
|
|
267
|
+
getProjectsPath,
|
|
268
|
+
getProjectDir,
|
|
269
|
+
getMonthCsvPath,
|
|
270
|
+
fmtElapsed,
|
|
271
|
+
expandHome,
|
|
272
|
+
ensureWorkHome,
|
|
273
|
+
ensureDir,
|
|
274
|
+
ensureCsvHeader,
|
|
275
|
+
appendCsvRow,
|
|
276
|
+
HEADER as CSV_HEADER
|
|
277
|
+
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibecoded/work",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
4
4
|
"description": "Work: CLI Time Tracker",
|
|
5
|
-
"
|
|
5
|
+
"main": "dist/lib.js",
|
|
6
|
+
"module": "dist/lib.js",
|
|
6
7
|
"scripts": {
|
|
7
|
-
"build": "bun build ./src/cli.ts --
|
|
8
|
+
"build": "bun build ./src/cli.ts --outfile=dist/cli.js --target=node && bun build ./src/lib/index.ts --outfile=dist/lib.js --target=node",
|
|
8
9
|
"prepublishOnly": "bun run build"
|
|
9
10
|
},
|
|
10
11
|
"devDependencies": {
|
|
@@ -21,6 +22,11 @@
|
|
|
21
22
|
"bin": {
|
|
22
23
|
"work": "dist/cli.js"
|
|
23
24
|
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./dist/lib.js",
|
|
27
|
+
"./cli": "./dist/cli.js",
|
|
28
|
+
"./lib": "./dist/lib.js"
|
|
29
|
+
},
|
|
24
30
|
"files": [
|
|
25
31
|
"dist",
|
|
26
32
|
"README.md"
|