@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +28 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +19 -0
- package/.github/workflows/release.yml +63 -0
- package/.github/workflows/validate.yml +103 -0
- package/.prettierrc +8 -0
- package/ARCHITECTURE.md +82 -0
- package/CODE_OF_CONDUCT.md +65 -0
- package/CONTRIBUTING.md +74 -0
- package/INSTALLATION.md +98 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/SECURITY.md +18 -0
- package/benchmarks/benchmark.ts +149 -0
- package/benchmarks/vitest.bench.config.ts +9 -0
- package/dist/database/migrations/001_initial.sql +60 -0
- package/dist/database/migrations/002_relational_tables.sql +22 -0
- package/dist/index.cjs +31007 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1873 -0
- package/dist/index.js.map +1 -0
- package/eslint.config.js +38 -0
- package/package.json +49 -0
- package/pnpm-workspace.yaml +6 -0
- package/scripts/build-bin.js +75 -0
- package/sea-config.json +5 -0
- package/src/cli/commands/add.ts +176 -0
- package/src/cli/commands/continue.ts +80 -0
- package/src/cli/commands/dashboard.ts +30 -0
- package/src/cli/commands/export.ts +72 -0
- package/src/cli/commands/init.ts +52 -0
- package/src/cli/commands/list.ts +33 -0
- package/src/cli/commands/search.ts +33 -0
- package/src/cli/commands/show.ts +42 -0
- package/src/cli/commands/stats.ts +23 -0
- package/src/cli/commands/today.ts +27 -0
- package/src/cli/commands/week.ts +93 -0
- package/src/cli/index.ts +294 -0
- package/src/cli/ui.ts +323 -0
- package/src/core/config.ts +69 -0
- package/src/core/discovery.ts +38 -0
- package/src/core/logger.ts +53 -0
- package/src/database/connection.ts +95 -0
- package/src/database/migration-data.ts +71 -0
- package/src/database/migrations/001_initial.sql +60 -0
- package/src/database/migrations/002_relational_tables.sql +22 -0
- package/src/database/migrations.ts +65 -0
- package/src/errors/index.ts +51 -0
- package/src/exporters/csv.ts +59 -0
- package/src/exporters/json.ts +8 -0
- package/src/exporters/markdown.ts +71 -0
- package/src/models/work-unit.ts +38 -0
- package/src/repositories/work-unit.ts +466 -0
- package/src/services/work-unit.ts +131 -0
- package/src/tests/cli/__snapshots__/cli-productivity.test.ts.snap +37 -0
- package/src/tests/cli/__snapshots__/cli.test.ts.snap +20 -0
- package/src/tests/cli/cli-productivity.test.ts +167 -0
- package/src/tests/cli/cli.test.ts +171 -0
- package/src/tests/core/config.test.ts +53 -0
- package/src/tests/core/discovery.test.ts +46 -0
- package/src/tests/database/migrations.test.ts +75 -0
- package/src/tests/repositories/work-unit.test.ts +243 -0
- package/src/tests/services/work-unit.test.ts +87 -0
- package/src/validators/work-unit.ts +63 -0
- package/tsconfig.json +16 -0
- package/tsup.config.ts +50 -0
- 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
|
+
`;
|