@vanshbhardwaj/worklog 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.
Files changed (68) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +28 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +19 -0
  4. package/.github/workflows/release.yml +63 -0
  5. package/.github/workflows/validate.yml +103 -0
  6. package/.prettierrc +8 -0
  7. package/ARCHITECTURE.md +82 -0
  8. package/CODE_OF_CONDUCT.md +65 -0
  9. package/CONTRIBUTING.md +74 -0
  10. package/INSTALLATION.md +98 -0
  11. package/LICENSE +21 -0
  12. package/README.md +246 -0
  13. package/SECURITY.md +18 -0
  14. package/benchmarks/benchmark.ts +149 -0
  15. package/benchmarks/vitest.bench.config.ts +9 -0
  16. package/dist/database/migrations/001_initial.sql +60 -0
  17. package/dist/database/migrations/002_relational_tables.sql +22 -0
  18. package/dist/index.cjs +31007 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1873 -0
  22. package/dist/index.js.map +1 -0
  23. package/eslint.config.js +38 -0
  24. package/package.json +49 -0
  25. package/pnpm-workspace.yaml +6 -0
  26. package/scripts/build-bin.js +75 -0
  27. package/sea-config.json +5 -0
  28. package/src/cli/commands/add.ts +176 -0
  29. package/src/cli/commands/continue.ts +80 -0
  30. package/src/cli/commands/dashboard.ts +30 -0
  31. package/src/cli/commands/export.ts +72 -0
  32. package/src/cli/commands/init.ts +52 -0
  33. package/src/cli/commands/list.ts +33 -0
  34. package/src/cli/commands/search.ts +33 -0
  35. package/src/cli/commands/show.ts +42 -0
  36. package/src/cli/commands/stats.ts +23 -0
  37. package/src/cli/commands/today.ts +27 -0
  38. package/src/cli/commands/week.ts +93 -0
  39. package/src/cli/index.ts +294 -0
  40. package/src/cli/ui.ts +323 -0
  41. package/src/core/config.ts +69 -0
  42. package/src/core/discovery.ts +38 -0
  43. package/src/core/logger.ts +53 -0
  44. package/src/database/connection.ts +95 -0
  45. package/src/database/migration-data.ts +71 -0
  46. package/src/database/migrations/001_initial.sql +60 -0
  47. package/src/database/migrations/002_relational_tables.sql +22 -0
  48. package/src/database/migrations.ts +65 -0
  49. package/src/errors/index.ts +51 -0
  50. package/src/exporters/csv.ts +59 -0
  51. package/src/exporters/json.ts +8 -0
  52. package/src/exporters/markdown.ts +71 -0
  53. package/src/models/work-unit.ts +38 -0
  54. package/src/repositories/work-unit.ts +466 -0
  55. package/src/services/work-unit.ts +131 -0
  56. package/src/tests/cli/__snapshots__/cli-productivity.test.ts.snap +37 -0
  57. package/src/tests/cli/__snapshots__/cli.test.ts.snap +20 -0
  58. package/src/tests/cli/cli-productivity.test.ts +167 -0
  59. package/src/tests/cli/cli.test.ts +171 -0
  60. package/src/tests/core/config.test.ts +53 -0
  61. package/src/tests/core/discovery.test.ts +46 -0
  62. package/src/tests/database/migrations.test.ts +75 -0
  63. package/src/tests/repositories/work-unit.test.ts +243 -0
  64. package/src/tests/services/work-unit.test.ts +87 -0
  65. package/src/validators/work-unit.ts +63 -0
  66. package/tsconfig.json +16 -0
  67. package/tsup.config.ts +50 -0
  68. package/vitest.config.ts +8 -0
@@ -0,0 +1,466 @@
1
+ import Database from 'better-sqlite3';
2
+ import { WorkUnit, WorkUnitType, WorkUnitStatus, WorkUnitPriority } from '../models/work-unit.js';
3
+ import { DatabaseError } from '../errors/index.js';
4
+ import { logger } from '../core/logger.js';
5
+
6
+ export interface SearchFilters {
7
+ tag?: string;
8
+ module?: string;
9
+ status?: string;
10
+ type?: string;
11
+ }
12
+
13
+ export interface WorkUnitStats {
14
+ totalCount: number;
15
+ typeDistribution: { type: string; count: number }[];
16
+ statusDistribution: { status: string; count: number }[];
17
+ moduleDistribution: { module: string; count: number }[];
18
+ dailyActivity: { day: string; count: number }[];
19
+ }
20
+
21
+ export class WorkUnitRepository {
22
+ constructor(private db: Database.Database) {}
23
+
24
+ /**
25
+ * Creates a new WorkUnit in the database.
26
+ * Inserts relations into work_unit_tags and work_unit_relations under a transaction.
27
+ */
28
+ create(workUnitData: Omit<WorkUnit, 'id'>): WorkUnit {
29
+ const createTx = this.db.transaction(() => {
30
+ // 1. Insert into main work_units table
31
+ const insertUnit = this.db.prepare(`
32
+ INSERT INTO work_units (
33
+ title, type, status, priority, module, description, next_step, decision, blocker, notes, created_at, updated_at
34
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
35
+ `);
36
+
37
+ const result = insertUnit.run(
38
+ workUnitData.title,
39
+ workUnitData.type,
40
+ workUnitData.status,
41
+ workUnitData.priority,
42
+ workUnitData.module,
43
+ workUnitData.description,
44
+ workUnitData.nextStep,
45
+ workUnitData.decision,
46
+ workUnitData.blocker,
47
+ workUnitData.notes,
48
+ workUnitData.createdAt,
49
+ workUnitData.updatedAt,
50
+ );
51
+
52
+ const workUnitId = Number(result.lastInsertRowid);
53
+
54
+ // 2. Insert tags
55
+ if (workUnitData.tags && workUnitData.tags.length > 0) {
56
+ const insertTag = this.db.prepare(`
57
+ INSERT INTO work_unit_tags (work_unit_id, tag) VALUES (?, ?)
58
+ `);
59
+ for (const tag of workUnitData.tags) {
60
+ insertTag.run(workUnitId, tag);
61
+ }
62
+ }
63
+
64
+ // 3. Insert relations
65
+ if (workUnitData.relatedWorkUnits && workUnitData.relatedWorkUnits.length > 0) {
66
+ const insertRelation = this.db.prepare(`
67
+ INSERT INTO work_unit_relations (work_unit_id, related_work_unit_id) VALUES (?, ?)
68
+ `);
69
+ const checkStmt = this.db.prepare('SELECT 1 FROM work_units WHERE id = ?');
70
+ for (const relId of workUnitData.relatedWorkUnits) {
71
+ if (!checkStmt.get(relId)) {
72
+ throw new DatabaseError(`Related work unit with ID ${relId} does not exist.`);
73
+ }
74
+ insertRelation.run(workUnitId, relId);
75
+ }
76
+ }
77
+
78
+ return workUnitId;
79
+ });
80
+
81
+ try {
82
+ const id = createTx();
83
+ return this.findById(id)!;
84
+ } catch (error: any) {
85
+ if (error instanceof DatabaseError) throw error;
86
+ throw new DatabaseError('Failed to create work unit', error.message);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Fetches a WorkUnit by ID and joins related tags and relations.
92
+ */
93
+ findById(id: number): WorkUnit | null {
94
+ try {
95
+ // 1. Fetch main record
96
+ const selectUnit = this.db.prepare(`
97
+ SELECT * FROM work_units WHERE id = ?
98
+ `);
99
+ const row = selectUnit.get(id) as any;
100
+ if (!row) {
101
+ return null;
102
+ }
103
+
104
+ // 2. Fetch tags
105
+ const selectTags = this.db.prepare(`
106
+ SELECT tag FROM work_unit_tags WHERE work_unit_id = ? ORDER BY tag ASC
107
+ `);
108
+ const tags = selectTags.all(id).map((r: any) => r.tag);
109
+
110
+ // 3. Fetch relations
111
+ const selectRelations = this.db.prepare(`
112
+ SELECT related_work_unit_id FROM work_unit_relations WHERE work_unit_id = ? ORDER BY related_work_unit_id ASC
113
+ `);
114
+ const related = selectRelations.all(id).map((r: any) => Number(r.related_work_unit_id));
115
+
116
+ // 4. Map DB row to Domain Model
117
+ return {
118
+ id: Number(row.id),
119
+ title: row.title,
120
+ type: row.type as WorkUnitType,
121
+ status: row.status as WorkUnitStatus,
122
+ priority: row.priority as WorkUnitPriority | null,
123
+ module: row.module,
124
+ description: row.description,
125
+ nextStep: row.next_step,
126
+ decision: row.decision,
127
+ blocker: row.blocker,
128
+ tags,
129
+ relatedWorkUnits: related,
130
+ notes: row.notes,
131
+ createdAt: row.created_at,
132
+ updatedAt: row.updated_at,
133
+ };
134
+ } catch (error: any) {
135
+ throw new DatabaseError(`Failed to find work unit by ID ${id}`, error.message);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Updates an existing WorkUnit.
141
+ * Handles differential updates for fields, tags, and relations.
142
+ */
143
+ update(
144
+ id: number,
145
+ workUnitData: Partial<Omit<WorkUnit, 'id' | 'createdAt'>> & { updatedAt: string },
146
+ ): WorkUnit {
147
+ const updateTx = this.db.transaction(() => {
148
+ // 1. Check if exists
149
+ const existing = this.findById(id);
150
+ if (!existing) {
151
+ throw new DatabaseError(`Work unit with ID ${id} not found.`);
152
+ }
153
+
154
+ // 2. Build SET clause dynamically
155
+ const updates: string[] = [];
156
+ const values: any[] = [];
157
+
158
+ const fieldsMap: { [key: string]: string } = {
159
+ title: 'title',
160
+ type: 'type',
161
+ status: 'status',
162
+ priority: 'priority',
163
+ module: 'module',
164
+ description: 'description',
165
+ nextStep: 'next_step',
166
+ decision: 'decision',
167
+ blocker: 'blocker',
168
+ notes: 'notes',
169
+ updatedAt: 'updated_at',
170
+ };
171
+
172
+ for (const [key, dbCol] of Object.entries(fieldsMap)) {
173
+ if (key in workUnitData) {
174
+ updates.push(`${dbCol} = ?`);
175
+ values.push((workUnitData as any)[key]);
176
+ }
177
+ }
178
+
179
+ if (updates.length > 0) {
180
+ values.push(id);
181
+ const updateStmt = this.db.prepare(`
182
+ UPDATE work_units SET ${updates.join(', ')} WHERE id = ?
183
+ `);
184
+ updateStmt.run(...values);
185
+ }
186
+
187
+ // 3. Update tags if provided
188
+ if (workUnitData.tags !== undefined) {
189
+ const deleteTags = this.db.prepare('DELETE FROM work_unit_tags WHERE work_unit_id = ?');
190
+ deleteTags.run(id);
191
+
192
+ if (workUnitData.tags.length > 0) {
193
+ const insertTag = this.db.prepare(
194
+ 'INSERT INTO work_unit_tags (work_unit_id, tag) VALUES (?, ?)',
195
+ );
196
+ for (const tag of workUnitData.tags) {
197
+ insertTag.run(id, tag);
198
+ }
199
+ }
200
+ }
201
+
202
+ // 4. Update relations if provided
203
+ if (workUnitData.relatedWorkUnits !== undefined) {
204
+ const deleteRelations = this.db.prepare(
205
+ 'DELETE FROM work_unit_relations WHERE work_unit_id = ?',
206
+ );
207
+ deleteRelations.run(id);
208
+
209
+ if (workUnitData.relatedWorkUnits.length > 0) {
210
+ const insertRelation = this.db.prepare(
211
+ 'INSERT INTO work_unit_relations (work_unit_id, related_work_unit_id) VALUES (?, ?)',
212
+ );
213
+ const checkStmt = this.db.prepare('SELECT 1 FROM work_units WHERE id = ?');
214
+ for (const relId of workUnitData.relatedWorkUnits) {
215
+ if (!checkStmt.get(relId)) {
216
+ throw new DatabaseError(`Related work unit with ID ${relId} does not exist.`);
217
+ }
218
+ insertRelation.run(id, relId);
219
+ }
220
+ }
221
+ }
222
+ });
223
+
224
+ try {
225
+ updateTx();
226
+ return this.findById(id)!;
227
+ } catch (error: any) {
228
+ if (error instanceof DatabaseError) throw error;
229
+ throw new DatabaseError(`Failed to update work unit by ID ${id}`, error.message);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Deletes a WorkUnit. Returns true if changes were made.
235
+ */
236
+ delete(id: number): boolean {
237
+ try {
238
+ const deleteStmt = this.db.prepare('DELETE FROM work_units WHERE id = ?');
239
+ const info = deleteStmt.run(id);
240
+ return info.changes > 0;
241
+ } catch (error: any) {
242
+ throw new DatabaseError(`Failed to delete work unit by ID ${id}`, error.message);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Finds the most recent work units, ordered by creation date descending.
248
+ */
249
+ findRecent(limit: number = 10): WorkUnit[] {
250
+ try {
251
+ const selectIds = this.db.prepare(`
252
+ SELECT id FROM work_units ORDER BY created_at DESC LIMIT ?
253
+ `);
254
+ const rows = selectIds.all(limit);
255
+ return rows.map((row: any) => this.findById(row.id) as WorkUnit);
256
+ } catch (error: any) {
257
+ throw new DatabaseError('Failed to fetch recent work units', error.message);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Finds work units matching a specific status.
263
+ */
264
+ findByStatus(status: WorkUnitStatus): WorkUnit[] {
265
+ try {
266
+ const selectIds = this.db.prepare(`
267
+ SELECT id FROM work_units WHERE status = ? ORDER BY created_at DESC
268
+ `);
269
+ const rows = selectIds.all(status);
270
+ return rows.map((row: any) => this.findById(row.id) as WorkUnit);
271
+ } catch (error: any) {
272
+ throw new DatabaseError(`Failed to fetch work units by status: ${status}`, error.message);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Finds work units matching a specific type.
278
+ */
279
+ findByType(type: WorkUnitType): WorkUnit[] {
280
+ try {
281
+ const selectIds = this.db.prepare(`
282
+ SELECT id FROM work_units WHERE type = ? ORDER BY created_at DESC
283
+ `);
284
+ const rows = selectIds.all(type);
285
+ return rows.map((row: any) => this.findById(row.id) as WorkUnit);
286
+ } catch (error: any) {
287
+ throw new DatabaseError(`Failed to fetch work units by type: ${type}`, error.message);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Finds all unfinished work units (Planned, In Progress, Blocked) ordered by updatedAt descending.
293
+ */
294
+ findUnfinished(): WorkUnit[] {
295
+ try {
296
+ const selectIds = this.db.prepare(`
297
+ SELECT id FROM work_units
298
+ WHERE status IN ('Planned', 'In Progress', 'Blocked')
299
+ ORDER BY updated_at DESC
300
+ `);
301
+ const rows = selectIds.all();
302
+ return rows.map((row: any) => this.findById(row.id) as WorkUnit);
303
+ } catch (error: any) {
304
+ throw new DatabaseError('Failed to fetch unfinished work units', error.message);
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Finds work units created or updated since an ISO timestamp.
310
+ */
311
+ findCreatedOrUpdatedAfter(isoString: string): WorkUnit[] {
312
+ try {
313
+ const selectIds = this.db.prepare(`
314
+ SELECT id FROM work_units
315
+ WHERE created_at >= ? OR updated_at >= ?
316
+ ORDER BY created_at DESC
317
+ `);
318
+ const rows = selectIds.all(isoString, isoString);
319
+ return rows.map((row: any) => this.findById(row.id) as WorkUnit);
320
+ } catch (error: any) {
321
+ throw new DatabaseError(
322
+ `Failed to fetch work units updated after ${isoString}`,
323
+ error.message,
324
+ );
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Runs analytical SQL queries to fetch count aggregates.
330
+ */
331
+ getStats(sinceIsoString: string): WorkUnitStats {
332
+ try {
333
+ // 1. Total Count
334
+ const totalCount = (
335
+ this.db.prepare('SELECT COUNT(*) as count FROM work_units').get() as any
336
+ ).count;
337
+
338
+ // 2. Type Distribution
339
+ const typeRows = this.db
340
+ .prepare(
341
+ 'SELECT type, COUNT(*) as count FROM work_units GROUP BY type ORDER BY count DESC',
342
+ )
343
+ .all() as any[];
344
+ const typeDistribution = typeRows.map((r) => ({ type: r.type, count: r.count }));
345
+
346
+ // 3. Status Distribution
347
+ const statusRows = this.db
348
+ .prepare(
349
+ 'SELECT status, COUNT(*) as count FROM work_units GROUP BY status ORDER BY count DESC',
350
+ )
351
+ .all() as any[];
352
+ const statusDistribution = statusRows.map((r) => ({ status: r.status, count: r.count }));
353
+
354
+ // 4. Module Distribution
355
+ const moduleRows = this.db
356
+ .prepare(`
357
+ SELECT module, COUNT(*) as count FROM work_units
358
+ WHERE module IS NOT NULL AND module != ''
359
+ GROUP BY module ORDER BY count DESC
360
+ `)
361
+ .all() as any[];
362
+ const moduleDistribution = moduleRows.map((r) => ({ module: r.module, count: r.count }));
363
+
364
+ // 5. Daily activity: Group by Date parsed from ISO string
365
+ const dailyRows = this.db
366
+ .prepare(`
367
+ SELECT date(created_at) as day, COUNT(*) as count FROM work_units
368
+ WHERE created_at >= ?
369
+ GROUP BY day ORDER BY day ASC
370
+ `)
371
+ .all(sinceIsoString) as any[];
372
+ const dailyActivity = dailyRows.map((r) => ({ day: r.day, count: r.count }));
373
+
374
+ return {
375
+ totalCount,
376
+ typeDistribution,
377
+ statusDistribution,
378
+ moduleDistribution,
379
+ dailyActivity,
380
+ };
381
+ } catch (error: any) {
382
+ throw new DatabaseError('Failed to fetch aggregate statistics', error.message);
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Case-insensitive, structured multi-criteria search combining FTS5 and standard filters.
388
+ */
389
+ search(query: string, filters: SearchFilters = {}): WorkUnit[] {
390
+ try {
391
+ let ids: number[] = [];
392
+ const hasQuery = query.trim().length > 0;
393
+
394
+ if (hasQuery) {
395
+ const trimmed = query.trim();
396
+ // FTS5 MATCH format
397
+ const ftsQuery = `"${trimmed}" OR ${trimmed}*`;
398
+ try {
399
+ const stmt = this.db.prepare(`
400
+ SELECT rowid as id FROM work_units_fts WHERE work_units_fts MATCH ?
401
+ `);
402
+ ids = stmt.all(ftsQuery).map((r: any) => Number(r.id));
403
+ } catch (ftsError: any) {
404
+ logger.warn({ ftsError, query }, 'FTS MATCH failed; using fallback LIKE list');
405
+ // If FTS fails (due to query syntax), do a fallback retrieve of all IDs containing query in LIKE
406
+ const fallbackStmt = this.db.prepare(`
407
+ SELECT id FROM work_units
408
+ WHERE title LIKE ? OR description LIKE ? OR notes LIKE ? OR decision LIKE ? OR blocker LIKE ?
409
+ `);
410
+ const likeParam = `%${trimmed}%`;
411
+ ids = fallbackStmt
412
+ .all(likeParam, likeParam, likeParam, likeParam, likeParam)
413
+ .map((r: any) => Number(r.id));
414
+ }
415
+
416
+ if (ids.length === 0) {
417
+ return []; // no text matches, exit early
418
+ }
419
+ }
420
+
421
+ // Build conditional SQL query
422
+ const clauses: string[] = [];
423
+ const params: any[] = [];
424
+
425
+ if (hasQuery) {
426
+ // Chunk ID binds if too large, but for standard search length we place binds directly
427
+ clauses.push(`id IN (${ids.map(() => '?').join(',')})`);
428
+ params.push(...ids);
429
+ }
430
+
431
+ if (filters.module) {
432
+ clauses.push('LOWER(module) = LOWER(?)');
433
+ params.push(filters.module);
434
+ }
435
+
436
+ if (filters.status) {
437
+ clauses.push('LOWER(status) = LOWER(?)');
438
+ params.push(filters.status);
439
+ }
440
+
441
+ if (filters.type) {
442
+ clauses.push('LOWER(type) = LOWER(?)');
443
+ params.push(filters.type);
444
+ }
445
+
446
+ if (filters.tag) {
447
+ clauses.push(`id IN (
448
+ SELECT work_unit_id FROM work_unit_tags WHERE LOWER(tag) = LOWER(?)
449
+ )`);
450
+ params.push(filters.tag);
451
+ }
452
+
453
+ const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
454
+ const sql = `
455
+ SELECT id FROM work_units
456
+ ${whereClause}
457
+ ORDER BY created_at DESC
458
+ `;
459
+
460
+ const rows = this.db.prepare(sql).all(...params);
461
+ return rows.map((row: any) => this.findById(row.id) as WorkUnit);
462
+ } catch (error: any) {
463
+ throw new DatabaseError(`Search operation failed: ${error.message}`);
464
+ }
465
+ }
466
+ }
@@ -0,0 +1,131 @@
1
+ import { SearchFilters, WorkUnitRepository, WorkUnitStats } from '../repositories/work-unit.js';
2
+ import {
3
+ CreateWorkUnitSchema,
4
+ UpdateWorkUnitSchema,
5
+ } from '../validators/work-unit.js';
6
+ import { WorkUnit, WorkUnitType, WorkUnitStatus } from '../models/work-unit.js';
7
+ import { ValidationError } from '../errors/index.js';
8
+
9
+ export class WorkUnitService {
10
+ constructor(private repo: WorkUnitRepository) {}
11
+
12
+ /**
13
+ * Validates creation input at the service boundary, assigns initial timestamps, and persists to database.
14
+ */
15
+ createWorkUnit(input: unknown): WorkUnit {
16
+ const result = CreateWorkUnitSchema.safeParse(input);
17
+ if (!result.success) {
18
+ const errorMsg = result.error.issues
19
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
20
+ .join(', ');
21
+ throw new ValidationError(errorMsg);
22
+ }
23
+
24
+ const validated = result.data;
25
+ const now = new Date().toISOString();
26
+
27
+ const workUnitData: Omit<WorkUnit, 'id'> = {
28
+ ...validated,
29
+ createdAt: now,
30
+ updatedAt: now,
31
+ };
32
+
33
+ return this.repo.create(workUnitData);
34
+ }
35
+
36
+ /**
37
+ * Validates update input, updates timestamp, and runs repository update statement.
38
+ */
39
+ updateWorkUnit(id: number, input: unknown): WorkUnit {
40
+ if (!id || id <= 0 || !Number.isInteger(id)) {
41
+ throw new ValidationError('A valid positive integer ID is required.');
42
+ }
43
+
44
+ const result = UpdateWorkUnitSchema.safeParse(input);
45
+ if (!result.success) {
46
+ const errorMsg = result.error.issues
47
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
48
+ .join(', ');
49
+ throw new ValidationError(errorMsg);
50
+ }
51
+
52
+ const validated = result.data;
53
+ const now = new Date().toISOString();
54
+
55
+ const workUnitData: Partial<Omit<WorkUnit, 'id' | 'createdAt'>> & { updatedAt: string } = {
56
+ ...validated,
57
+ updatedAt: now,
58
+ };
59
+
60
+ return this.repo.update(id, workUnitData);
61
+ }
62
+
63
+ /**
64
+ * Fetches a WorkUnit by its ID. Throws on negative/decimal values.
65
+ */
66
+ getWorkUnit(id: number): WorkUnit | null {
67
+ if (!id || id <= 0 || !Number.isInteger(id)) {
68
+ throw new ValidationError('A valid positive integer ID is required.');
69
+ }
70
+ return this.repo.findById(id);
71
+ }
72
+
73
+ /**
74
+ * Deletes a WorkUnit by ID. Returns true if deleted.
75
+ */
76
+ deleteWorkUnit(id: number): boolean {
77
+ if (!id || id <= 0 || !Number.isInteger(id)) {
78
+ throw new ValidationError('A valid positive integer ID is required.');
79
+ }
80
+ return this.repo.delete(id);
81
+ }
82
+
83
+ /**
84
+ * Retrieves recent WorkUnits.
85
+ */
86
+ getRecentWorkUnits(limit?: number): WorkUnit[] {
87
+ return this.repo.findRecent(limit);
88
+ }
89
+
90
+ /**
91
+ * Retrieves WorkUnits filtered by Status.
92
+ */
93
+ getWorkUnitsByStatus(status: WorkUnitStatus): WorkUnit[] {
94
+ return this.repo.findByStatus(status);
95
+ }
96
+
97
+ /**
98
+ * Retrieves WorkUnits filtered by Type.
99
+ */
100
+ getWorkUnitsByType(type: WorkUnitType): WorkUnit[] {
101
+ return this.repo.findByType(type);
102
+ }
103
+
104
+ /**
105
+ * Retrieves all unfinished Work Units (Planned, In Progress, Blocked) sorted by updatedAt descending.
106
+ */
107
+ getUnfinishedWorkUnits(): WorkUnit[] {
108
+ return this.repo.findUnfinished();
109
+ }
110
+
111
+ /**
112
+ * Retrieves Work Units created or updated since an ISO timestamp.
113
+ */
114
+ getCreatedOrUpdatedAfter(isoString: string): WorkUnit[] {
115
+ return this.repo.findCreatedOrUpdatedAfter(isoString);
116
+ }
117
+
118
+ /**
119
+ * Retrieves SQLite aggregated statistics.
120
+ */
121
+ getStats(sinceIsoString: string): WorkUnitStats {
122
+ return this.repo.getStats(sinceIsoString);
123
+ }
124
+
125
+ /**
126
+ * Searches WorkUnits by text query and structured filters.
127
+ */
128
+ searchWorkUnits(query: string, filters: SearchFilters = {}): WorkUnit[] {
129
+ return this.repo.search(query, filters);
130
+ }
131
+ }
@@ -0,0 +1,37 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`WorkLog CLI Productivity Commands > should display dashboard (snapshot) 1`] = `
4
+ "=== WORKLOG DASHBOARD ===
5
+
6
+ CURRENTLY IN PROGRESS:
7
+ #2 Tags task
8
+
9
+ NEEDS ATTENTION:
10
+ #1 [Planned] Unicode test ☕ 汉字
11
+
12
+ RECENTLY UPDATED:
13
+ #2 [[DATE], [TIME]] Tags task (In Progress)
14
+ #1 [[DATE], [TIME]] Unicode test ☕ 汉字 (Planned)
15
+ "
16
+ `;
17
+
18
+ exports[`WorkLog CLI Productivity Commands > should display stats summary (snapshot) 1`] = `
19
+ "=== ENGINEERING INSIGHTS ===
20
+
21
+ Total Work Units: 2
22
+
23
+ STATUS DISTRIBUTION:
24
+ Planned : 1
25
+ In Progress : 1
26
+
27
+ TYPE DISTRIBUTION:
28
+ Idea : 1
29
+ Bug : 1
30
+
31
+ MODULE DISTRIBUTION:
32
+ No modules tracked.
33
+
34
+ ACTIVITY IN THE PAST 7 DAYS:
35
+ [DATE] : ** (2)
36
+ "
37
+ `;
@@ -0,0 +1,20 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`WorkLog CLI Integration > should list work units in a beautiful table format (snapshot) 1`] = `
4
+ "ID Title Type Status Module Priority Created At
5
+ #2 Second Task Bug In Progress - - [DATE]
6
+ #1 First Task Feature Planned auth - [DATE]
7
+ "
8
+ `;
9
+
10
+ exports[`WorkLog CLI Integration > should show card details for a specific unit (snapshot) 1`] = `
11
+ "[#1] First Task
12
+ --------------------------------------------------
13
+ Type: Feature
14
+ Status: Planned
15
+ Module: auth
16
+ Tags: login, security
17
+ Created: [DATE]
18
+ Updated: [DATE]
19
+ "
20
+ `;