summaryrefslogtreecommitdiff
path: root/internal/store/native_tasks.go
blob: 11a9197642c4f0b40f7a8f895b5a4bea8925ac20 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
package store

import (
	"crypto/rand"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

	"task-dashboard/internal/config"
	"task-dashboard/internal/models"
)

// ErrNativeTaskNotFound is returned by CompleteNativeTask, UncompleteNativeTask,
// and RescheduleNativeTask when no row matches the given id -- previously these
// three silently reported success on a 0-row UPDATE (Exec's err is nil even when
// no rows match), so a stale or wrong id from a caller looked identical to a real
// completion: the HTTP response was 200, but nothing in the database changed.
var ErrNativeTaskNotFound = errors.New("native task not found")

// GetNativeTasks returns all non-completed native tasks.
func (s *Store) GetNativeTasks() ([]models.Task, error) {
	rows, err := s.db.Query(`
		SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
		       recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
		       chain_id, chain_position, chain_unlocked,
		       bucket_id, bucket_state, bucket_last_active_at
		FROM native_tasks
		WHERE completed = 0
		ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, priority DESC
	`)
	if err != nil {
		return nil, err
	}
	defer func() { _ = rows.Close() }()
	return scanNativeTasks(rows)
}

// GetNativeTasksByDateRange returns non-completed native tasks due within the given range.
// Overdue tasks (due before start) are deliberately excluded here -- BuildTimeline fetches
// those separately via GetOverdueNativeTasks so callers that only want "in range" can use this
// without double-counting against that separate fetch.
func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) {
	rows, err := s.db.Query(`
		SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
		       recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
		       chain_id, chain_position, chain_unlocked,
		       bucket_id, bucket_state, bucket_last_active_at
		FROM native_tasks
		WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ?
		  AND (chain_id = '' OR chain_unlocked = 1)
		ORDER BY due_date ASC, priority DESC
	`, start, end)
	if err != nil {
		return nil, err
	}
	defer func() { _ = rows.Close() }()
	return scanNativeTasks(rows)
}

// GetOverdueNativeTasks returns non-completed native tasks whose due date is
// before the given time. BuildTimeline calls this alongside
// GetNativeTasksByDateRange, whose lower bound excludes anything due before
// the requested range's start -- without this, a task overdue from a
// previous day never gets fetched at all, so it never reaches
// ComputeDaySection to be marked IsOverdue.
func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) {
	rows, err := s.db.Query(`
		SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
		       recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
		       chain_id, chain_position, chain_unlocked,
		       bucket_id, bucket_state, bucket_last_active_at
		FROM native_tasks
		WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ?
		  AND (chain_id = '' OR chain_unlocked = 1)
		ORDER BY due_date ASC, priority DESC
	`, before)
	if err != nil {
		return nil, err
	}
	defer func() { _ = rows.Close() }()
	return scanNativeTasks(rows)
}

// GetUndatedNativeTasks returns non-completed native tasks with no due date.
func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) {
	rows, err := s.db.Query(`
		SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
		       recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
		       chain_id, chain_position, chain_unlocked,
		       bucket_id, bucket_state, bucket_last_active_at
		FROM native_tasks
		WHERE completed = 0 AND due_date IS NULL
		  AND (chain_id = '' OR chain_unlocked = 1)
		  AND bucket_state != 'dormant'
		ORDER BY priority DESC, created_at ASC
	`)
	if err != nil {
		return nil, err
	}
	defer func() { _ = rows.Close() }()
	return scanNativeTasks(rows)
}

// GetNativeTaskByID returns a single native task by id, or ErrNativeTaskNotFound.
func (s *Store) GetNativeTaskByID(id string) (*models.Task, error) {
	rows, err := s.db.Query(`
		SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
		       recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
		       chain_id, chain_position, chain_unlocked,
		       bucket_id, bucket_state, bucket_last_active_at
		FROM native_tasks
		WHERE id = ?
	`, id)
	if err != nil {
		return nil, err
	}
	defer func() { _ = rows.Close() }()
	tasks, err := scanNativeTasks(rows)
	if err != nil {
		return nil, err
	}
	if len(tasks) == 0 {
		return nil, ErrNativeTaskNotFound
	}
	return &tasks[0], nil
}

// CreateNativeTask inserts a new native task.
func (s *Store) CreateNativeTask(task models.Task) error {
	labelsJSON, _ := json.Marshal(task.Labels)
	_, err := s.db.Exec(`
		INSERT INTO native_tasks (id, content, description, project_name, project_id, due_date, priority, labels, estimated_minutes, created_at, updated_at)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
	`, task.ID, task.Content, task.Description, task.ProjectName, task.ProjectID, task.DueDate, task.Priority, string(labelsJSON), task.EstimatedMinutes)
	return err
}

// UpdateNativeTask updates a native task's content and description.
// Returns ErrNativeTaskNotFound if id doesn't match any row.
func (s *Store) UpdateNativeTask(id, content, description string) error {
	result, err := s.db.Exec(`
		UPDATE native_tasks SET content = ?, description = ?, updated_at = CURRENT_TIMESTAMP
		WHERE id = ?
	`, content, description, id)
	if err != nil {
		return err
	}
	return checkRowsAffected(result)
}

// UpdateNativeTaskDescription updates only a native task's description, leaving content untouched.
func (s *Store) UpdateNativeTaskDescription(id, description string) error {
	_, err := s.db.Exec(`
		UPDATE native_tasks SET description = ?, updated_at = CURRENT_TIMESTAMP
		WHERE id = ?
	`, description, id)
	return err
}

// ErrChainTaskLocked is returned by CompleteNativeTask when the task
// belongs to a chain but isn't the currently-unlocked position -- without
// this guard, completing a locked task directly by id (bypassing the UI,
// which never renders a checkbox for locked chain tasks) would still run
// advanceChain against the wrong position, breaking the chain's WIP-1
// invariant.
var ErrChainTaskLocked = errors.New("task is locked in its chain")

// CompleteNativeTask marks a task as completed. If it's the latest
// occurrence of a recurring series (no newer row exists yet), it also
// creates the next iteration. Returns ErrNativeTaskNotFound if id doesn't
// match any row, or ErrChainTaskLocked if it's a locked (not yet
// actionable) chain task.
func (s *Store) CompleteNativeTask(id string) error {
	task, err := s.GetNativeTaskByID(id)
	if err != nil {
		return err
	}
	if task.ChainID != "" && !task.ChainUnlocked {
		return ErrChainTaskLocked
	}

	result, err := s.db.Exec(`
		UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?
	`, id)
	if err != nil {
		return err
	}
	if err := checkRowsAffected(result); err != nil {
		return err
	}

	if task.ChainID != "" {
		chain, err := s.GetChain(task.ChainID)
		if err != nil {
			return err
		}
		// A paused chain does not auto-advance -- completing its unlocked
		// task is still allowed (it's the one actionable step), but the
		// successor stays locked until the chain is explicitly resumed
		// (see SetChainStatus's resume catch-up).
		if chain.Status != "paused" {
			if err := s.advanceChain(task.ChainID, task.ChainPosition); err != nil {
				return err
			}
		}
	}

	if task.BucketID != "" {
		now := config.Now()
		if _, err := s.db.Exec(`
			UPDATE native_tasks SET bucket_state = 'dormant', due_date = NULL, bucket_last_active_at = ?, updated_at = ? WHERE id = ?
		`, now, now, id); err != nil {
			return err
		}
	}

	if task.RecurrenceSeriesID == "" {
		return nil
	}
	isLatest, err := s.isLatestInSeries(*task)
	if err != nil {
		return err
	}
	if !isLatest {
		return nil
	}
	return s.CreateNextIteration(*task)
}

// isLatestInSeries reports whether task is the row with the latest
// due_date in its recurrence series (i.e., no newer iteration has been
// created yet). Ties on due_date are broken by created_at: the
// more-recently-created row wins, so CreateNextIteration's freshly-inserted
// row always displaces the row it was generated from, never the reverse.
func (s *Store) isLatestInSeries(task models.Task) (bool, error) {
	var exists bool
	err := s.db.QueryRow(`
		SELECT EXISTS (
			SELECT 1 FROM native_tasks
			WHERE recurrence_series_id = ?
			  AND (due_date > ? OR (due_date = ? AND created_at > ?))
		)
	`, task.RecurrenceSeriesID, task.DueDate, task.DueDate, task.CreatedAt).Scan(&exists)
	if err != nil {
		return false, err
	}
	return !exists, nil
}

// RescheduleNativeTask sets a new due date on a task. Returns
// ErrNativeTaskNotFound if id doesn't match any row.
func (s *Store) RescheduleNativeTask(id string, dueDate time.Time) error {
	result, err := s.db.Exec(`
		UPDATE native_tasks SET due_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
	`, dueDate, id)
	if err != nil {
		return err
	}
	return checkRowsAffected(result)
}

// SetTaskEstimate sets a task's estimated duration in minutes. Returns
// ErrNativeTaskNotFound if id doesn't match any row.
func (s *Store) SetTaskEstimate(id string, minutes int) error {
	result, err := s.db.Exec(`
		UPDATE native_tasks SET estimated_minutes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
	`, minutes, id)
	if err != nil {
		return err
	}
	return checkRowsAffected(result)
}

// UncompleteNativeTask marks a task as not completed. Returns
// ErrNativeTaskNotFound if id doesn't match any row.
func (s *Store) UncompleteNativeTask(id string) error {
	result, err := s.db.Exec(`
		UPDATE native_tasks SET completed = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?
	`, id)
	if err != nil {
		return err
	}
	return checkRowsAffected(result)
}

// CreateNextIteration copies old's content, description, project_name,
// priority, labels, and recurrence fields onto a brand-new row (new id,
// same recurrence_series_id, completed=false, next_occurrence_override=""),
// with due_date set to old.NextOccurrenceOverride if present, else
// ComputeNextOccurrence(old.DueDate, ...). old itself is left untouched.
// The INSERT is self-guarding: it atomically no-ops if a newer row already
// exists in the series, so two concurrent triggers (the completion path and
// the periodic due-date check) can never both create a successor for the
// same predecessor.
func (s *Store) CreateNextIteration(old models.Task) error {
	var nextDue *time.Time
	switch {
	case old.NextOccurrenceOverride != nil:
		nextDue = old.NextOccurrenceOverride
	case old.DueDate != nil:
		computed := models.ComputeNextOccurrence(*old.DueDate, old.RecurrenceFreq, old.RecurrenceInterval, old.RecurrenceWeekdays)
		nextDue = &computed
	}

	labelsJSON, _ := json.Marshal(old.Labels)
	_, err := s.db.Exec(`
		INSERT INTO native_tasks (
			id, content, description, project_name, project_id, due_date, priority, labels, estimated_minutes,
			recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override,
			created_at, updated_at
		)
		SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
		WHERE NOT EXISTS (
			SELECT 1 FROM native_tasks
			WHERE recurrence_series_id = ?
			  AND (due_date > ? OR (due_date = ? AND created_at > ?))
		)
	`, newTaskID(), old.Content, old.Description, old.ProjectName, old.ProjectID, nextDue, old.Priority, string(labelsJSON), old.EstimatedMinutes,
		old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID,
		old.RecurrenceSeriesID, old.DueDate, old.DueDate, old.CreatedAt)
	return err
}

// newTaskID generates a random hex id for a new native_tasks row -- the
// same format as handlers.newID(), duplicated here since store must not
// import handlers.
func newTaskID() string {
	b := make([]byte, 12)
	_, _ = rand.Read(b)
	return fmt.Sprintf("%x", b)
}

// SetTaskRecurrence sets or clears a task's recurrence pattern. freq == ""
// clears the pattern (recurrence_series_id is left untouched so history
// stays linkable -- a cleared task just stops generating new iterations).
// Setting a freq for the first time (existing recurrence_series_id is
// empty) generates a new series id. Returns ErrNativeTaskNotFound if id
// doesn't match any row.
func (s *Store) SetTaskRecurrence(id, freq string, interval int, weekdays []int) error {
	task, err := s.GetNativeTaskByID(id)
	if err != nil {
		return err
	}

	seriesID := task.RecurrenceSeriesID
	if freq != "" && seriesID == "" {
		seriesID = newTaskID()
	}

	result, err := s.db.Exec(`
		UPDATE native_tasks
		SET recurrence_freq = ?, recurrence_interval = ?, recurrence_weekdays = ?, recurrence_series_id = ?, updated_at = CURRENT_TIMESTAMP
		WHERE id = ?
	`, freq, interval, formatWeekdays(weekdays), seriesID, id)
	if err != nil {
		return err
	}
	return checkRowsAffected(result)
}

// SetNextOccurrenceOverride sets a one-shot override for a task's next
// occurrence, consumed (read, but not explicitly cleared -- the override
// column simply isn't copied onto the new row) the next time
// CreateNextIteration runs for its series. Returns ErrNativeTaskNotFound if
// id doesn't match any row.
func (s *Store) SetNextOccurrenceOverride(id string, date time.Time) error {
	result, err := s.db.Exec(`
		UPDATE native_tasks SET next_occurrence_override = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
	`, date.Format("2006-01-02"), id)
	if err != nil {
		return err
	}
	return checkRowsAffected(result)
}

// GetSeriesNeedingNextIteration returns the latest row of every recurring
// series whose due_date has arrived (<= now) and which has no newer row
// yet in its series -- regardless of completed state, so a series whose
// synchronous CreateNextIteration call (from CompleteNativeTask) somehow
// failed still gets healed on the next tick, and so an uncompleted,
// ignored recurring task doesn't block its successor from appearing.
func (s *Store) GetSeriesNeedingNextIteration(now time.Time) ([]models.Task, error) {
	rows, err := s.db.Query(`
		SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
		       recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
		       chain_id, chain_position, chain_unlocked,
		       bucket_id, bucket_state, bucket_last_active_at
		FROM native_tasks t1
		WHERE recurrence_series_id != ''
		  AND due_date IS NOT NULL AND due_date <= ?
		  AND NOT EXISTS (
		    SELECT 1 FROM native_tasks t2
		    WHERE t2.recurrence_series_id = t1.recurrence_series_id
		      AND (t2.due_date > t1.due_date
		           OR (t2.due_date = t1.due_date AND t2.created_at > t1.created_at))
		  )
	`, now)
	if err != nil {
		return nil, err
	}
	defer func() { _ = rows.Close() }()
	return scanNativeTasks(rows)
}

// AdvanceDueRecurringTasks creates the next iteration for every recurring
// series whose latest row is due (or overdue) and has no successor yet.
// Returns the number of iterations created.
func (s *Store) AdvanceDueRecurringTasks(now time.Time) (int, error) {
	series, err := s.GetSeriesNeedingNextIteration(now)
	if err != nil {
		return 0, err
	}
	for _, task := range series {
		if err := s.CreateNextIteration(task); err != nil {
			return 0, err
		}
	}
	return len(series), nil
}

// checkRowsAffected returns ErrNativeTaskNotFound if the update matched no
// rows -- mirrors the RowsAffected() check already used in sqlite.go's
// ApproveAgentSession/DenyAgentSession for the same "silent 0-row update"
// class of bug.
func checkRowsAffected(result sql.Result) error {
	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected == 0 {
		return ErrNativeTaskNotFound
	}
	return nil
}

func scanNativeTasks(rows interface {
	Next() bool
	Scan(...interface{}) error
	Err() error
}) ([]models.Task, error) {
	var tasks []models.Task
	for rows.Next() {
		var t models.Task
		var labelsJSON string
		var dueDateStr *string
		var weekdaysStr string
		var nextOverrideStr string
		var bucketLastActiveAt sql.NullTime
		if err := rows.Scan(
			&t.ID, &t.Content, &t.Description, &t.ProjectName, &t.ProjectID, &dueDateStr, &t.Priority, &t.Completed, &labelsJSON, &t.CreatedAt,
			&t.RecurrenceFreq, &t.RecurrenceInterval, &weekdaysStr, &t.RecurrenceSeriesID, &nextOverrideStr, &t.EstimatedMinutes,
			&t.ChainID, &t.ChainPosition, &t.ChainUnlocked,
			&t.BucketID, &t.BucketState, &bucketLastActiveAt,
		); err != nil {
			return nil, err
		}
		if bucketLastActiveAt.Valid {
			t.BucketLastActiveAt = &bucketLastActiveAt.Time
		}
		if dueDateStr != nil {
			if parsed, err := time.Parse(time.RFC3339, *dueDateStr); err == nil {
				t.DueDate = &parsed
			} else if parsed, err := time.Parse("2006-01-02 15:04:05", *dueDateStr); err == nil {
				t.DueDate = &parsed
			} else if parsed, err := time.Parse("2006-01-02", *dueDateStr); err == nil {
				t.DueDate = &parsed
			}
		}
		if err := json.Unmarshal([]byte(labelsJSON), &t.Labels); err != nil {
			t.Labels = nil
		}
		t.RecurrenceWeekdays = parseWeekdays(weekdaysStr)
		if nextOverrideStr != "" {
			if parsed, err := time.Parse("2006-01-02", nextOverrideStr); err == nil {
				t.NextOccurrenceOverride = &parsed
			}
		}
		tasks = append(tasks, t)
	}
	return tasks, rows.Err()
}

// parseWeekdays parses a comma-separated list of 0-6 ints (e.g. "1,3,5"),
// returning nil for an empty string.
func parseWeekdays(s string) []int {
	if s == "" {
		return nil
	}
	parts := strings.Split(s, ",")
	weekdays := make([]int, 0, len(parts))
	for _, p := range parts {
		if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
			weekdays = append(weekdays, n)
		}
	}
	return weekdays
}

// formatWeekdays is the inverse of parseWeekdays.
func formatWeekdays(weekdays []int) string {
	if len(weekdays) == 0 {
		return ""
	}
	strs := make([]string, len(weekdays))
	for i, d := range weekdays {
		strs[i] = strconv.Itoa(d)
	}
	return strings.Join(strs, ",")
}