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:
1 parent
300fd6a7be
commit
344f5e4ab2
21 files changed
+391
-50
No files matched your search
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAlarmDue(t *testing.T) {
|
||||
berlin, _ := time.LoadLocation("Europe/Berlin")
|
||||
at := func(h, m int) time.Time { return time.Date(2026, 10, 30, h, m, 0, 0, berlin) }
|
||||
cases := []struct {
|
||||
due string
|
||||
before int
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{"2026-10-30", 0, at(8, 59), false},
|
||||
{"2026-10-30", 0, at(9, 0), true},
|
||||
{"2026-10-30T14:30", 0, at(14, 29), false},
|
||||
{"2026-10-30T14:30", 30, at(14, 0), true},
|
||||
{"2026-10-29", 0, at(0, 1), true},
|
||||
{"bald", 0, at(23, 0), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := alarmDue(c.due, c.before, c.now); got != c.want {
|
||||
t.Errorf("alarmDue(%q, %d, %v) = %v, want %v", c.due, c.before, c.now.Format("15:04"), got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -3199,6 +3199,13 @@ func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes,
|
||||
return nil
|
||||
}
|
||||
|
||||
// alarmDue reports whether the alarm for a due date should go out now: from the due time (09:00 for
|
||||
// all-day tasks) minus the user's lead time on. Unreadable dates never fire.
|
||||
func alarmDue(dueDate string, notifyBeforeMinutes int, now time.Time) bool {
|
||||
at, ok := domain.DueAlarmAt(dueDate, notifyBeforeMinutes, now.Location())
|
||||
return ok && !now.Before(at)
|
||||
}
|
||||
|
||||
func (s *Server) startAlarmTicker() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -3209,10 +3216,10 @@ func (s *Server) startAlarmTicker() {
|
||||
continue
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
now := time.Now()
|
||||
|
||||
for _, a := range alarms {
|
||||
if a.DueDate > today {
|
||||
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
|
||||
continue
|
||||
}
|
||||
if len(a.AlarmChannels) == 0 {
|
||||
@@ -3220,6 +3227,7 @@ func (s *Server) startAlarmTicker() {
|
||||
}
|
||||
|
||||
sentAny := false
|
||||
due := domain.HumanDue(a.DueDate)
|
||||
desc := a.Notes
|
||||
if desc == "" {
|
||||
desc = "Aufgabe ist jetzt fällig."
|
||||
@@ -3229,7 +3237,7 @@ func (s *Server) startAlarmTicker() {
|
||||
switch ch {
|
||||
case "discord":
|
||||
if a.DiscordWebhookURL != "" {
|
||||
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||||
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, due, a.Priority); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
@@ -3241,7 +3249,7 @@ func (s *Server) startAlarmTicker() {
|
||||
}
|
||||
case "email":
|
||||
if a.AlarmEmail != "" {
|
||||
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, a.DueDate, a.Priority, desc)
|
||||
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, due, a.Priority, desc)
|
||||
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Due dates are stored as "2006-01-02" (all day) or "2006-01-02T15:04" (with time), always in the
|
||||
// server's local time zone (TZ, default Europe/Berlin). Both sort correctly as plain strings.
|
||||
const (
|
||||
dueDateLayout = "2006-01-02"
|
||||
dueDateTimeLayout = "2006-01-02T15:04"
|
||||
)
|
||||
|
||||
// AllDayAlarmHour is when alarms for tasks without a time fire.
|
||||
const AllDayAlarmHour = 9
|
||||
|
||||
// NormalizeDue brings accepted spellings ("2006-01-02 15:04", seconds, RFC3339) to the stored form.
|
||||
// Values it cannot read are kept as they are so no input gets lost.
|
||||
func NormalizeDue(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if t, hasTime, ok := ParseDue(raw, time.Local); ok {
|
||||
if hasTime {
|
||||
return t.Format(dueDateTimeLayout)
|
||||
}
|
||||
return t.Format(dueDateLayout)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ParseDue reads a due date in loc. hasTime is false for all-day dates (then t is midnight).
|
||||
func ParseDue(raw string, loc *time.Location) (t time.Time, hasTime, ok bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if t, err := time.ParseInLocation(dueDateLayout, raw, loc); err == nil {
|
||||
return t, false, true
|
||||
}
|
||||
for _, layout := range []string{dueDateTimeLayout, "2006-01-02 15:04", "2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
|
||||
if t, err := time.ParseInLocation(layout, raw, loc); err == nil {
|
||||
return t, true, true
|
||||
}
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return t.In(loc), true, true
|
||||
}
|
||||
return time.Time{}, false, false
|
||||
}
|
||||
|
||||
// DueAlarmAt is the moment the alarm for a due date fires: the due time (09:00 for all-day tasks)
|
||||
// minus the user's lead time.
|
||||
func DueAlarmAt(raw string, notifyBeforeMinutes int, loc *time.Location) (time.Time, bool) {
|
||||
t, hasTime, ok := ParseDue(raw, loc)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if !hasTime {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), AllDayAlarmHour, 0, 0, 0, loc)
|
||||
}
|
||||
if notifyBeforeMinutes > 0 {
|
||||
t = t.Add(-time.Duration(notifyBeforeMinutes) * time.Minute)
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// HumanDue renders a due date for messages: "30.10.2026" or "30.10.2026, 14:30 Uhr".
|
||||
func HumanDue(raw string) string {
|
||||
t, hasTime, ok := ParseDue(raw, time.Local)
|
||||
if !ok {
|
||||
return raw
|
||||
}
|
||||
if hasTime {
|
||||
return t.Format("02.01.2006, 15:04") + " Uhr"
|
||||
}
|
||||
return t.Format("02.01.2006")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeDue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"2026-10-30": "2026-10-30",
|
||||
"2026-10-30T14:30": "2026-10-30T14:30",
|
||||
"2026-10-30 14:30": "2026-10-30T14:30",
|
||||
"2026-10-30T14:30:59": "2026-10-30T14:30",
|
||||
" ": "",
|
||||
"nächste Woche": "nächste Woche",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizeDue(in); got != want {
|
||||
t.Errorf("NormalizeDue(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueAlarmAt(t *testing.T) {
|
||||
berlin, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at, ok := DueAlarmAt("2026-10-30", 0, berlin)
|
||||
if !ok || !at.Equal(time.Date(2026, 10, 30, 9, 0, 0, 0, berlin)) {
|
||||
t.Errorf("all-day alarm should fire at 09:00 local, got %v", at)
|
||||
}
|
||||
at, _ = DueAlarmAt("2026-10-30T14:30", 15, berlin)
|
||||
if !at.Equal(time.Date(2026, 10, 30, 14, 15, 0, 0, berlin)) {
|
||||
t.Errorf("lead time not applied: %v", at)
|
||||
}
|
||||
if _, ok := DueAlarmAt("irgendwann", 0, berlin); ok {
|
||||
t.Errorf("unreadable due date must not produce an alarm")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user