feat(tasks): due dates with optional time; alarms at the right moment

Due dates can carry a time (YYYY-MM-DDTHH:MM). Alarms fire at that time,
or 09:00 for all-day tasks, minus the user's lead time, in TZ (default
Europe/Berlin, tzdata embedded). A new due date or reopening a task
re-arms the alarm. Recurrence keeps the time; Markdown export/import
carry it. The UI shows relative labels ("Heute, 14:30"), marks overdue
tasks to the minute and groups by local day instead of the UTC date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 07:48:37 +01:00
1 parent 300fd6a7be
commit 344f5e4ab2
21 files changed
+391 -50

No files matched your search

+2 -1
View File
@@ -141,7 +141,8 @@ func taskMarkdownLine(t domain.Thought) string {
line += " 🔁 every " + every
}
if t.DueDate != "" {
line += " 📅 " + t.DueDate
// Obsidian Tasks only knows the date; the time follows separated by a space
line += " 📅 " + strings.Replace(t.DueDate, "T", " ", 1)
}
var sb strings.Builder
sb.WriteString(line + "\n")
+4 -1
View File
@@ -759,7 +759,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
var (
recurRe = regexp.MustCompile(`(?i)[ \t]*🔁[ \t]*every[ \t]+(day|week|month|year)\b`)
dueRe = regexp.MustCompile(`[ \t]*📅[ \t]*(\d{4}-\d{2}-\d{2})`)
dueRe = regexp.MustCompile(`[ \t]*📅[ \t]*(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}))?`)
priorityRe = regexp.MustCompile(`[ \t]*(🔺|⏫|🔼|🔽|⏬)`)
)
@@ -801,6 +801,9 @@ func parseTaskFile(body string) []importTask {
}
if d := dueRe.FindStringSubmatch(t.content); d != nil {
t.due = d[1]
if d[2] != "" {
t.due += "T" + d[2]
}
t.content = dueRe.ReplaceAllString(t.content, "")
}
if p := priorityRe.FindStringSubmatch(t.content); p != nil {
+10 -5
View File
@@ -18,12 +18,14 @@ func validRecurrence(r string) bool {
return false
}
// NextDueDate moves a due date (YYYY-MM-DD, or today when empty) one interval forward. Months and years
// keep the day where possible and fall back to the month's last day (31 Jan -> 28/29 Feb).
// NextDueDate moves a due date (YYYY-MM-DD or YYYY-MM-DDTHH:MM, today when empty) one interval forward
// and keeps the time. Months and years keep the day where possible and fall back to the month's last
// day (31 Jan -> 28/29 Feb).
func NextDueDate(due, rule string, today time.Time) string {
base, err := time.Parse("2006-01-02", due)
if err != nil {
base = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
parsed, hasTime, ok := domain.ParseDue(due, time.UTC)
base := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
if ok {
base = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
}
switch rule {
case "daily":
@@ -35,6 +37,9 @@ func NextDueDate(due, rule string, today time.Time) string {
case "yearly":
base = addMonthsClamped(base, 12)
}
if hasTime {
return base.Format("2006-01-02") + parsed.Format("T15:04")
}
return base.Format("2006-01-02")
}
+43
View File
@@ -17,6 +17,8 @@ func TestNextDueDate(t *testing.T) {
{"2026-03-15", "monthly", "2026-04-15"},
{"2028-02-29", "yearly", "2029-02-28"},
{"", "weekly", "2026-11-05"},
{"2026-01-31T07:15", "monthly", "2026-02-28T07:15"},
{"2026-10-30T23:30", "daily", "2026-10-31T23:30"},
}
for _, c := range cases {
if got := NextDueDate(c.due, c.rule, today); got != c.want {
@@ -95,3 +97,44 @@ func TestRepeatingTaskSpawnsItsSuccessorOnce(t *testing.T) {
t.Errorf("non-repeating task must not spawn")
}
}
func TestAlarmResetsOnNewDueDateOrReopen(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "alarm.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
u, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
task, _ := store.CreateThoughtWithDetails(u.ID, "Zahnarzt", "normal", "manual", "2026-10-30 14:30", "", "todo", "")
_, _ = store.UpdateThoughtTriage(u.ID, task.ID, "keep", "task", "", "", "")
sent := func() bool {
th, _ := store.GetThoughtByID(u.ID, task.ID)
return th.AlarmSent
}
if th, _ := store.GetThoughtByID(u.ID, task.ID); th.DueDate != "2026-10-30T14:30" {
t.Fatalf("due date not normalized: %q", th.DueDate)
}
_ = store.MarkTaskAlarmSent(task.ID)
same := "2026-10-30T14:30"
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &same, nil, nil, nil, nil, nil, nil)
if !sent() {
t.Errorf("unchanged due date must not re-arm the alarm")
}
later := "2026-10-31T08:00"
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &later, nil, nil, nil, nil, nil, nil)
if sent() {
t.Errorf("new due date must re-arm the alarm")
}
_ = store.MarkTaskAlarmSent(task.ID)
done, open := true, false
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
if !sent() {
t.Errorf("completing keeps the flag")
}
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, &open, nil, nil, nil, nil, nil, nil, nil)
if sent() {
t.Errorf("reopening must re-arm the alarm")
}
}
+18 -7
View File
@@ -867,6 +867,7 @@ func (s *Store) CreateThought(userID, content, priority, source, dueDate string)
func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) {
content = strings.TrimSpace(content)
dueDate = domain.NormalizeDue(dueDate)
if content == "" {
return nil, errors.New("content cannot be empty")
}
@@ -1098,8 +1099,9 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
}
}
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, it.ID,
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID,
)
} else {
_, err = tx.Exec(
@@ -1132,6 +1134,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
newStatus := domain.StatusKept
convertedType := thought.ConvertedType
convertedRefID := thought.ConvertedRefID
dueDate = domain.NormalizeDue(dueDate)
if dueDate == "" {
dueDate = thought.DueDate
}
@@ -1194,7 +1197,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
}
var metaItems []string
if thought.DueDate != "" {
metaItems = append(metaItems, "Fällig: "+thought.DueDate)
metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate))
}
if thought.Priority != "" && thought.Priority != "normal" {
metaItems = append(metaItems, "Priorität: "+thought.Priority)
@@ -1233,9 +1236,10 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
_, err = s.db.Exec(
`UPDATE thoughts
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?,
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END
WHERE id = ? AND user_id = ?`,
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, thoughtID, userID,
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID,
)
if err != nil {
return nil, err
@@ -1253,6 +1257,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
return nil, err
}
wasCompleted := thought.IsCompleted
oldDue := thought.DueDate
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
@@ -1278,7 +1283,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
}
}
if dueDate != nil {
thought.DueDate = *dueDate
thought.DueDate = domain.NormalizeDue(*dueDate)
}
if priority != nil {
p := strings.ToLower(strings.TrimSpace(*priority))
@@ -1321,6 +1326,11 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
if err != nil {
return nil, err
}
// A new due date or a reopened task gets its alarm again
if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) {
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID)
thought.AlarmSent = false
}
if taskListID != nil {
s.PruneTaskAssignees()
thought.Assignees = s.getTaskAssignees(thoughtID)
@@ -3967,7 +3977,8 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
doneInt = 1
kanban = "done"
}
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, taskID, userID, doneInt)
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt)
if checked && res != nil {
if n, _ := res.RowsAffected(); n > 0 {
s.spawnNextOccurrence(taskID)