loopwork 0.3.0 → 0.3.1

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 (46) hide show
  1. package/bin/loopwork +0 -0
  2. package/package.json +48 -4
  3. package/src/backends/github.ts +6 -3
  4. package/src/backends/json.ts +28 -10
  5. package/src/commands/run.ts +2 -2
  6. package/src/contracts/config.ts +3 -75
  7. package/src/contracts/index.ts +0 -6
  8. package/src/core/cli.ts +25 -16
  9. package/src/core/state.ts +10 -4
  10. package/src/core/utils.ts +10 -4
  11. package/src/monitor/index.ts +56 -34
  12. package/src/plugins/index.ts +9 -131
  13. package/examples/README.md +0 -70
  14. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
  15. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
  16. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
  17. package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
  18. package/examples/basic-json-backend/README.md +0 -32
  19. package/examples/basic-json-backend/TESTING.md +0 -184
  20. package/examples/basic-json-backend/hello.test.ts +0 -9
  21. package/examples/basic-json-backend/hello.ts +0 -3
  22. package/examples/basic-json-backend/loopwork.config.js +0 -35
  23. package/examples/basic-json-backend/math.test.ts +0 -29
  24. package/examples/basic-json-backend/math.ts +0 -3
  25. package/examples/basic-json-backend/package.json +0 -15
  26. package/examples/basic-json-backend/quick-start.sh +0 -80
  27. package/loopwork.config.ts +0 -164
  28. package/src/plugins/asana.ts +0 -192
  29. package/src/plugins/cost-tracking.ts +0 -402
  30. package/src/plugins/discord.ts +0 -269
  31. package/src/plugins/everhour.ts +0 -335
  32. package/src/plugins/telegram/bot.ts +0 -517
  33. package/src/plugins/telegram/index.ts +0 -6
  34. package/src/plugins/telegram/notifications.ts +0 -198
  35. package/src/plugins/todoist.ts +0 -261
  36. package/test/backends.test.ts +0 -929
  37. package/test/cli.test.ts +0 -145
  38. package/test/config.test.ts +0 -90
  39. package/test/e2e.test.ts +0 -458
  40. package/test/github-tasks.test.ts +0 -191
  41. package/test/loopwork-config-types.test.ts +0 -288
  42. package/test/monitor.test.ts +0 -123
  43. package/test/plugins.test.ts +0 -1175
  44. package/test/state.test.ts +0 -295
  45. package/test/utils.test.ts +0 -60
  46. package/tsconfig.json +0 -20
@@ -1,335 +0,0 @@
1
- /**
2
- * Everhour Plugin for Loopwork
3
- *
4
- * Tracks time spent on tasks using Everhour API.
5
- * Tasks should have metadata.everhourId or metadata.asanaGid set.
6
- * If using Asana integration, asanaGid is auto-prefixed with 'as:' for Everhour.
7
- *
8
- * Setup:
9
- * 1. Get API key from Everhour Settings > Integrations > API
10
- * 2. Set EVERHOUR_API_KEY env var
11
- * 3. Link Everhour to your Asana project for automatic task syncing
12
- * 4. Add everhourId or asanaGid to task metadata
13
- */
14
-
15
- import type { LoopworkPlugin, PluginTask } from '../contracts'
16
-
17
- export interface EverhourConfig {
18
- apiKey?: string
19
- /** Auto-start timer when task begins */
20
- autoStartTimer?: boolean
21
- /** Auto-stop timer when task completes */
22
- autoStopTimer?: boolean
23
- /** Default project ID for new time entries */
24
- projectId?: string
25
- }
26
-
27
- interface EverhourTimeEntry {
28
- id: number
29
- time: number // seconds
30
- date: string
31
- task?: { id: string; name: string }
32
- user?: { id: number; name: string }
33
- }
34
-
35
- interface EverhourTask {
36
- id: string
37
- name: string
38
- time: { total: number; users?: Record<string, number> }
39
- }
40
-
41
- interface EverhourTimer {
42
- status: 'active' | 'stopped'
43
- duration: number
44
- task?: { id: string }
45
- startedAt?: string
46
- }
47
-
48
- export class EverhourClient {
49
- private baseUrl = 'https://api.everhour.com'
50
- private apiKey: string
51
-
52
- constructor(apiKey: string) {
53
- this.apiKey = apiKey
54
- }
55
-
56
- private async request<T>(
57
- method: string,
58
- endpoint: string,
59
- body?: Record<string, unknown>
60
- ): Promise<T> {
61
- const url = `${this.baseUrl}${endpoint}`
62
- const response = await fetch(url, {
63
- method,
64
- headers: {
65
- 'X-Api-Key': this.apiKey,
66
- 'Content-Type': 'application/json',
67
- },
68
- body: body ? JSON.stringify(body) : undefined,
69
- })
70
-
71
- if (!response.ok) {
72
- const error = await response.text()
73
- throw new Error(`Everhour API error: ${response.status} - ${error}`)
74
- }
75
-
76
- // Some endpoints return empty response
77
- const text = await response.text()
78
- if (!text) return {} as T
79
-
80
- return JSON.parse(text) as T
81
- }
82
-
83
- /**
84
- * Get current timer status
85
- */
86
- async getCurrentTimer(): Promise<EverhourTimer | null> {
87
- try {
88
- return await this.request('GET', '/timers/current')
89
- } catch {
90
- return null
91
- }
92
- }
93
-
94
- /**
95
- * Start timer for a task
96
- * @param taskId - Everhour task ID (for Asana tasks, use 'as:' prefix + GID)
97
- */
98
- async startTimer(taskId: string): Promise<EverhourTimer> {
99
- return this.request('POST', '/timers', { task: taskId })
100
- }
101
-
102
- /**
103
- * Stop the current timer
104
- */
105
- async stopTimer(): Promise<EverhourTimer> {
106
- return this.request('DELETE', '/timers/current')
107
- }
108
-
109
- /**
110
- * Add time entry for a task
111
- * @param taskId - Everhour task ID
112
- * @param seconds - Duration in seconds
113
- * @param date - Date in YYYY-MM-DD format (defaults to today)
114
- */
115
- async addTime(taskId: string, seconds: number, date?: string): Promise<EverhourTimeEntry> {
116
- const today = date || new Date().toISOString().split('T')[0]
117
- return this.request('POST', `/tasks/${taskId}/time`, {
118
- time: seconds,
119
- date: today,
120
- })
121
- }
122
-
123
- /**
124
- * Get time entries for a task
125
- */
126
- async getTaskTime(taskId: string): Promise<EverhourTask> {
127
- return this.request('GET', `/tasks/${taskId}`)
128
- }
129
-
130
- /**
131
- * Get today's time entries
132
- */
133
- async getTodayEntries(): Promise<EverhourTimeEntry[]> {
134
- const today = new Date().toISOString().split('T')[0]
135
- return this.request('GET', `/team/time?from=${today}&to=${today}`)
136
- }
137
-
138
- /**
139
- * Get total time logged today (in seconds)
140
- */
141
- async getTodayTotal(): Promise<number> {
142
- const entries = await this.getTodayEntries()
143
- return entries.reduce((sum, e) => sum + e.time, 0)
144
- }
145
-
146
- /**
147
- * Check if we're under the daily limit
148
- * @param maxHours - Maximum hours per day (default: 8)
149
- */
150
- async checkDailyLimit(maxHours = 8): Promise<{ withinLimit: boolean; hoursLogged: number; remaining: number }> {
151
- const totalSeconds = await this.getTodayTotal()
152
- const hoursLogged = totalSeconds / 3600
153
- const remaining = Math.max(0, maxHours - hoursLogged)
154
- return {
155
- withinLimit: hoursLogged < maxHours,
156
- hoursLogged: Math.round(hoursLogged * 100) / 100,
157
- remaining: Math.round(remaining * 100) / 100,
158
- }
159
- }
160
-
161
- /**
162
- * Get user's current status
163
- */
164
- async getMe(): Promise<{ id: number; name: string; email: string }> {
165
- return this.request('GET', '/users/me')
166
- }
167
- }
168
-
169
- /**
170
- * Create Everhour plugin wrapper
171
- */
172
- export function withEverhour(config: EverhourConfig = {}) {
173
- const apiKey = config.apiKey || process.env.EVERHOUR_API_KEY
174
-
175
- return (baseConfig: any) => ({
176
- ...baseConfig,
177
- everhour: {
178
- apiKey,
179
- autoStartTimer: config.autoStartTimer ?? true,
180
- autoStopTimer: config.autoStopTimer ?? true,
181
- projectId: config.projectId,
182
- },
183
- })
184
- }
185
-
186
- /**
187
- * Get Everhour task ID from task metadata
188
- * Prefers everhourId, falls back to asanaGid with 'as:' prefix
189
- */
190
- function getEverhourTaskId(task: PluginTask): string | undefined {
191
- const everhourId = task.metadata?.everhourId as string | undefined
192
- if (everhourId) return everhourId
193
-
194
- const asanaGid = task.metadata?.asanaGid as string | undefined
195
- if (asanaGid) return asanaToEverhour(asanaGid)
196
-
197
- return undefined
198
- }
199
-
200
- // Track active timers per task
201
- const activeTimers: Map<string, { startTime: number; everhourTaskId?: string }> = new Map()
202
-
203
- /**
204
- * Create Everhour hook plugin
205
- *
206
- * Tasks should have metadata.everhourId or metadata.asanaGid set.
207
- */
208
- export function createEverhourPlugin(config: EverhourConfig = {}): LoopworkPlugin {
209
- const apiKey = config.apiKey || process.env.EVERHOUR_API_KEY || ''
210
- const autoStart = config.autoStartTimer ?? true
211
- const autoStop = config.autoStopTimer ?? true
212
-
213
- if (!apiKey) {
214
- return {
215
- name: 'everhour',
216
- onConfigLoad: (cfg) => {
217
- console.warn('Everhour plugin: Missing EVERHOUR_API_KEY')
218
- return cfg
219
- },
220
- }
221
- }
222
-
223
- const client = new EverhourClient(apiKey)
224
-
225
- return {
226
- name: 'everhour',
227
-
228
- async onLoopStart() {
229
- // Check daily limit at start
230
- try {
231
- const limit = await client.checkDailyLimit(8)
232
- if (!limit.withinLimit) {
233
- console.warn(`⚠️ Everhour: Already logged ${limit.hoursLogged}h today (limit: 8h)`)
234
- } else {
235
- console.log(`⏱️ Everhour: ${limit.hoursLogged}h logged, ${limit.remaining}h remaining`)
236
- }
237
- } catch (e: any) {
238
- console.warn(`Everhour: Failed to check daily limit: ${e.message}`)
239
- }
240
- },
241
-
242
- async onTaskStart(task) {
243
- // Record start time
244
- const startTime = Date.now()
245
- const everhourTaskId = getEverhourTaskId(task)
246
- activeTimers.set(task.id, { startTime, everhourTaskId })
247
-
248
- // Start Everhour timer if task has ID and autoStart enabled
249
- if (everhourTaskId && autoStart) {
250
- try {
251
- await client.startTimer(everhourTaskId)
252
- console.log(`⏱️ Timer started for ${task.id}`)
253
- } catch (e: any) {
254
- console.warn(`Everhour: Failed to start timer: ${e.message}`)
255
- }
256
- }
257
- },
258
-
259
- async onTaskComplete(task, result) {
260
- const timerInfo = activeTimers.get(task.id)
261
- activeTimers.delete(task.id)
262
-
263
- if (!timerInfo) return
264
-
265
- const { everhourTaskId } = timerInfo
266
- const durationSeconds = Math.round(result.duration)
267
-
268
- // Stop timer if running
269
- if (everhourTaskId && autoStop) {
270
- try {
271
- await client.stopTimer()
272
- console.log(`⏱️ Timer stopped for ${task.id} (${durationSeconds}s)`)
273
- } catch (e: any) {
274
- // Timer might not be running, that's ok
275
- console.warn(`Everhour: ${e.message}`)
276
- }
277
- }
278
-
279
- // Log time if we have an ID (backup in case timer wasn't running)
280
- if (everhourTaskId && !autoStart) {
281
- try {
282
- await client.addTime(everhourTaskId, durationSeconds)
283
- console.log(`⏱️ Logged ${durationSeconds}s to ${task.id}`)
284
- } catch (e: any) {
285
- console.warn(`Everhour: Failed to log time: ${e.message}`)
286
- }
287
- }
288
- },
289
-
290
- async onTaskFailed(task) {
291
- const timerInfo = activeTimers.get(task.id)
292
- activeTimers.delete(task.id)
293
-
294
- // Stop timer if running (don't log time for failed tasks by default)
295
- if (timerInfo?.everhourTaskId && autoStop) {
296
- try {
297
- await client.stopTimer()
298
- console.log(`⏱️ Timer stopped for failed task ${task.id}`)
299
- } catch {
300
- // Timer might not be running
301
- }
302
- }
303
- },
304
-
305
- async onLoopEnd(stats) {
306
- // Report final time summary
307
- try {
308
- const limit = await client.checkDailyLimit(8)
309
- console.log(`📊 Everhour: Session complete. Total today: ${limit.hoursLogged}h`)
310
- } catch {
311
- // Ignore errors in summary
312
- }
313
- },
314
- }
315
- }
316
-
317
- /**
318
- * Helper to convert Asana GID to Everhour task ID
319
- */
320
- export function asanaToEverhour(asanaGid: string): string {
321
- return `as:${asanaGid}`
322
- }
323
-
324
- /**
325
- * Format seconds to human readable duration
326
- */
327
- export function formatDuration(seconds: number): string {
328
- const hours = Math.floor(seconds / 3600)
329
- const minutes = Math.floor((seconds % 3600) / 60)
330
-
331
- if (hours > 0) {
332
- return `${hours}h ${minutes}m`
333
- }
334
- return `${minutes}m`
335
- }