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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user