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