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
@@ -60,6 +60,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
||||||
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) |
|
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) |
|
||||||
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
|
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
|
||||||
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
||||||
@@ -80,7 +81,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
|
|||||||
|
|
||||||
## Hintergrundjob: Alarme
|
## Hintergrundjob: Alarme
|
||||||
|
|
||||||
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
|
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
|
||||||
|
|
||||||
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
|
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|
|||||||
### Gedanken und Aufgaben
|
### Gedanken und Aufgaben
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||||
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
||||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||||
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
||||||
|
|||||||
+2
-1
@@ -21,7 +21,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
||||||
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
||||||
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
||||||
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
|
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** |
|
||||||
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
||||||
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
||||||
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
||||||
@@ -110,6 +110,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
|
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
|
||||||
|
|
||||||
### FLAW-12: Alarm-Logik
|
### FLAW-12: Alarm-Logik
|
||||||
|
- **Teilweise behoben:** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` zurück. Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`. Offen bleibt der letzte Punkt (kein Backoff).
|
||||||
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
|
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
|
||||||
- **Probleme:**
|
- **Probleme:**
|
||||||
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
|
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe
|
|||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
|
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (`YYYY-MM-DD` oder mit Uhrzeit `YYYY-MM-DDTHH:MM`; die API nimmt auch `YYYY-MM-DD HH:MM` an), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
|
||||||
|
|
||||||
## Ansichten
|
## Ansichten
|
||||||
|
|
||||||
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
||||||
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
||||||
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
||||||
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
||||||
|
|
||||||
## Aufgabenlisten und Freigabe
|
## Aufgabenlisten und Freigabe
|
||||||
@@ -31,14 +31,14 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
|||||||
|
|
||||||
- Im Detail-Fenster unter der Fälligkeit: *Keine Wiederholung*, täglich, wöchentlich, monatlich oder jährlich (`thoughts.recurrence`, `PATCH /thoughts/{id}` mit `recurrence`). Zeilen und Kanban-Karten zeigen dann ein 🔁-Symbol.
|
- Im Detail-Fenster unter der Fälligkeit: *Keine Wiederholung*, täglich, wöchentlich, monatlich oder jährlich (`thoughts.recurrence`, `PATCH /thoughts/{id}` mit `recurrence`). Zeilen und Kanban-Karten zeigen dann ein 🔁-Symbol.
|
||||||
- **Abhaken erzeugt die nächste Aufgabe** ([recurrence.go](../../internal/storage/recurrence.go)): egal ob im Detail, in der Liste, per Ziehen in „Erledigt“ im Kanban oder über die Checkbox in einer Notiz. Die erledigte Aufgabe bleibt erledigt (Historie); die neue übernimmt Titel, Tags, Priorität, Notizen, Liste, Zuweisungen und die Regel, Unteraufgaben kommen unerledigt mit, sie steht in der ersten offenen Spalte.
|
- **Abhaken erzeugt die nächste Aufgabe** ([recurrence.go](../../internal/storage/recurrence.go)): egal ob im Detail, in der Liste, per Ziehen in „Erledigt“ im Kanban oder über die Checkbox in einer Notiz. Die erledigte Aufgabe bleibt erledigt (Historie); die neue übernimmt Titel, Tags, Priorität, Notizen, Liste, Zuweisungen und die Regel, Unteraufgaben kommen unerledigt mit, sie steht in der ersten offenen Spalte.
|
||||||
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute). Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
|
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute); eine Uhrzeit bleibt erhalten. Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
|
||||||
- Jede Aufgabe erzeugt höchstens eine Nachfolgerin (`recurrence_spawned`): Wieder öffnen und erneut abhaken legt keine zweite an.
|
- Jede Aufgabe erzeugt höchstens eine Nachfolgerin (`recurrence_spawned`): Wieder öffnen und erneut abhaken legt keine zweite an.
|
||||||
- Die neue Aufgabe geht per `TASK_CREATED` an alle Beteiligten, sie erscheint ohne Neuladen.
|
- Die neue Aufgabe geht per `TASK_CREATED` an alle Beteiligten, sie erscheint ohne Neuladen.
|
||||||
- Export/Import: im Markdown-Archiv als `🔁 every week` (Format des Obsidian-Tasks-Plugins), im JSON-Backup als Feld; beides wird beim Import wiederhergestellt.
|
- Export/Import: im Markdown-Archiv als `🔁 every week` (Format des Obsidian-Tasks-Plugins), im JSON-Backup als Feld; beides wird beim Import wiederhergestellt.
|
||||||
|
|
||||||
## Alarme
|
## Alarme
|
||||||
|
|
||||||
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
|
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `alarm_sent = 0` sendet er, sobald der Alarmzeitpunkt erreicht ist (Uhrzeit der Fälligkeit, ganztägig 9:00, jeweils minus `notifyBeforeMinutes`, Zeitzone `TZ`), an die Kanäle des Aufgaben-**Eigentümers**:
|
||||||
|
|
||||||
| Kanal | Ziel | Konfiguration |
|
| Kanal | Ziel | Konfiguration |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -57,4 +57,6 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa
|
|||||||
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
|
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
|
||||||
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
|
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
|
||||||
|
|
||||||
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
|
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Offen ([FLAW-12](../flaws.md)): Schlagen alle Kanäle fehl, wird jede Minute erneut versucht. URLs werden geprüft ([FLAW-05](../flaws.md)).
|
||||||
|
|
||||||
|
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
|
||||||
@@ -153,6 +153,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
|||||||
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
||||||
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
|
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
|
||||||
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
|
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
|
||||||
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/api"
|
"github.com/sandrodach/mindsift/internal/api"
|
||||||
"github.com/sandrodach/mindsift/internal/config"
|
"github.com/sandrodach/mindsift/internal/config"
|
||||||
@@ -22,6 +23,12 @@ import (
|
|||||||
var embeddedDist embed.FS
|
var embeddedDist embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Due dates and alarms use the server's local time; without TZ that is Europe/Berlin
|
||||||
|
if os.Getenv("TZ") == "" {
|
||||||
|
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||||
|
time.Local = loc
|
||||||
|
}
|
||||||
|
}
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
level := cfg.SlogLevel()
|
level := cfg.SlogLevel()
|
||||||
|
|||||||
@@ -562,7 +562,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
<strong className="text-main">Wiederkehrende Aufgaben:</strong> Im Detail-Fenster eine Wiederholung wählen (täglich bis jährlich). Beim Abhaken entsteht automatisch die nächste Aufgabe mit fortgeschriebenem Datum; erkennbar am 🔁-Symbol.
|
<strong className="text-main">Wiederkehrende Aufgaben:</strong> Im Detail-Fenster eine Wiederholung wählen (täglich bis jährlich). Beim Abhaken entsteht automatisch die nächste Aufgabe mit fortgeschriebenem Datum; erkennbar am 🔁-Symbol.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Automatische Alarme:</strong> Ist ein Fälligkeitsdatum erreicht, kann MindSift dich automatisch per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook benachrichtigen. Konfigurierbar in deinen Benutzereinstellungen.
|
<strong className="text-main">Fälligkeit mit Uhrzeit:</strong> Im Detail-Fenster neben dem Datum optional eine Uhrzeit setzen; „✕ Uhrzeit“ macht die Aufgabe wieder ganztägig. Angezeigt wird z.B. „Heute, 14:30“, Überfälliges in Rot.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-main">Automatische Alarme:</strong> Zur Fälligkeit (ganztägige Aufgaben um 9:00, abzüglich deiner Vorlaufzeit) benachrichtigt MindSift dich per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook. Verschiebst du die Fälligkeit oder öffnest die Aufgabe wieder, kommt der Alarm erneut. Konfigurierbar in deinen Benutzereinstellungen.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { UserAvatar } from '../../components/UserAvatar';
|
|||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
import { triggerHaptic } from '../../lib/api';
|
import { triggerHaptic } from '../../lib/api';
|
||||||
import { MarkdownViewer } from '../../lib/markdown';
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
|
import { addDays, joinDue, splitDue } from '../../lib/due';
|
||||||
|
|
||||||
interface TaskDetailModalProps {
|
interface TaskDetailModalProps {
|
||||||
task: Thought | null;
|
task: Thought | null;
|
||||||
@@ -165,15 +166,15 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dueParts = splitDue(task.dueDate);
|
||||||
|
|
||||||
const handleQuickDueDate = (daysOffset: number | null) => {
|
const handleQuickDueDate = (daysOffset: number | null) => {
|
||||||
if (daysOffset === null) {
|
if (daysOffset === null) {
|
||||||
onUpdateDueDate(task, '');
|
onUpdateDueDate(task, '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const d = new Date();
|
// Keep a chosen time when only the day changes
|
||||||
d.setDate(d.getDate() + daysOffset);
|
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
|
||||||
const dateStr = d.toISOString().split('T')[0];
|
|
||||||
onUpdateDueDate(task, dateStr);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
|
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
|
||||||
@@ -469,21 +470,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Fälligkeitsdatum:</span>
|
<span>Fällig:</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={task.dueDate || ''}
|
aria-label="Fälligkeitsdatum"
|
||||||
onChange={(e) => onUpdateDueDate(task, e.target.value)}
|
value={dueParts.date}
|
||||||
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
|
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
|
||||||
|
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label="Uhrzeit (optional)"
|
||||||
|
title="Uhrzeit (optional) – ohne Uhrzeit gilt die Aufgabe den ganzen Tag"
|
||||||
|
value={dueParts.time}
|
||||||
|
disabled={!dueParts.date}
|
||||||
|
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
|
||||||
|
className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onUpdateDueDate(task, '')}
|
onClick={() => onUpdateDueDate(task, '')}
|
||||||
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
|
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center text-muted hover:text-status-err shrink-0"
|
||||||
title="Datum entfernen"
|
title="Fälligkeit entfernen"
|
||||||
|
aria-label="Fälligkeit entfernen"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -511,6 +523,17 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
>
|
>
|
||||||
+1 Woche
|
+1 Woche
|
||||||
</button>
|
</button>
|
||||||
|
{dueParts.time && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUpdateDueDate(task, dueParts.date)}
|
||||||
|
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main inline-flex items-center gap-1"
|
||||||
|
title="Uhrzeit entfernen – die Aufgabe gilt dann den ganzen Tag"
|
||||||
|
>
|
||||||
|
<X className="w-2.5 h-2.5" />
|
||||||
|
Uhrzeit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onUpdateRecurrence && (
|
{onUpdateRecurrence && (
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Repeat,
|
Repeat,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||||
|
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
@@ -416,10 +417,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
|
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
|
||||||
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
|
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
|
||||||
} else if (groupBy === 'due') {
|
} else if (groupBy === 'due') {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const now = new Date();
|
||||||
const overdue = filteredTasks.filter((t) => t.dueDate && t.dueDate < today);
|
const today = localDay(now);
|
||||||
const dueToday = filteredTasks.filter((t) => t.dueDate === today);
|
const overdue = filteredTasks.filter((t) => isOverdue(t.dueDate, now));
|
||||||
const upcoming = filteredTasks.filter((t) => t.dueDate && t.dueDate > today);
|
const dueToday = filteredTasks.filter((t) => isDueToday(t.dueDate, now) && !isOverdue(t.dueDate, now));
|
||||||
|
const upcoming = filteredTasks.filter((t) => t.dueDate && splitDue(t.dueDate).date > today);
|
||||||
const noDue = filteredTasks.filter((t) => !t.dueDate);
|
const noDue = filteredTasks.filter((t) => !t.dueDate);
|
||||||
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
|
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
|
||||||
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
|
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
|
||||||
@@ -1311,9 +1313,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
{/* Due Date */}
|
{/* Due Date */}
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] text-muted">
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 text-[10px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||||||
|
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
|
||||||
|
>
|
||||||
<Calendar className="w-2.5 h-2.5 text-accent" />
|
<Calendar className="w-2.5 h-2.5 text-accent" />
|
||||||
<span>{task.dueDate}</span>
|
<span>{formatDue(task.dueDate)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.recurrence && (
|
{task.recurrence && (
|
||||||
@@ -2335,9 +2340,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted">
|
<span
|
||||||
|
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||||||
|
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
|
||||||
|
>
|
||||||
<Calendar className="w-2.5 h-2.5" />
|
<Calendar className="w-2.5 h-2.5" />
|
||||||
<span>{task.dueDate}</span>
|
<span>{formatDue(task.dueDate)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.recurrence && (
|
{task.recurrence && (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SharedList, Thought } from '../../lib/types';
|
import { SharedList, Thought } from '../../lib/types';
|
||||||
|
import { addDays, formatDue } from '../../lib/due';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
|
||||||
interface TriageDeckProps {
|
interface TriageDeckProps {
|
||||||
@@ -223,10 +224,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
const [cards, setCards] = useState<Thought[]>(deck);
|
const [cards, setCards] = useState<Thought[]>(deck);
|
||||||
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
||||||
const [showDateModal, setShowDateModal] = useState(false);
|
const [showDateModal, setShowDateModal] = useState(false);
|
||||||
const [taskDueDate, setTaskDueDate] = useState(() => {
|
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
|
||||||
const tomorrow = new Date(Date.now() + 86400000);
|
|
||||||
return tomorrow.toISOString().slice(0, 10);
|
|
||||||
});
|
|
||||||
const [showListSelector, setShowListSelector] = useState(false);
|
const [showListSelector, setShowListSelector] = useState(false);
|
||||||
|
|
||||||
// Long-Press Action Sheet state (for Triage Card or Queue Item)
|
// Long-Press Action Sheet state (for Triage Card or Queue Item)
|
||||||
@@ -666,7 +664,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
|
|
||||||
{activeCard.dueDate && (
|
{activeCard.dueDate && (
|
||||||
<span className="px-2 py-0.5 bg-canvas text-muted border border-border rounded-btn text-xs">
|
<span className="px-2 py-0.5 bg-canvas text-muted border border-border rounded-btn text-xs">
|
||||||
📅 {activeCard.dueDate}
|
📅 {formatDue(activeCard.dueDate)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatDue, isOverdue, isDueToday, joinDue, splitDue } from '../due';
|
||||||
|
|
||||||
|
const now = new Date(2026, 9, 29, 14, 0); // Thu 29.10.2026, 14:00 local
|
||||||
|
|
||||||
|
describe('due dates', () => {
|
||||||
|
it('splits and joins date and time', () => {
|
||||||
|
expect(splitDue('2026-10-30T14:30')).toEqual({ date: '2026-10-30', time: '14:30' });
|
||||||
|
expect(splitDue('2026-10-30')).toEqual({ date: '2026-10-30', time: '' });
|
||||||
|
expect(joinDue('2026-10-30', '09:15')).toBe('2026-10-30T09:15');
|
||||||
|
expect(joinDue('2026-10-30', '')).toBe('2026-10-30');
|
||||||
|
expect(joinDue('', '09:15')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats relative to today', () => {
|
||||||
|
expect(formatDue('2026-10-29T14:30', now)).toBe('Heute, 14:30');
|
||||||
|
expect(formatDue('2026-10-30', now)).toBe('Morgen');
|
||||||
|
expect(formatDue('2026-10-28', now)).toBe('Gestern');
|
||||||
|
expect(formatDue('2026-11-06T08:00', now)).toBe('Fr., 06.11., 08:00');
|
||||||
|
expect(formatDue('2027-01-02', now)).toBe('02.01.2027');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows overdue to the minute', () => {
|
||||||
|
expect(isOverdue('2026-10-29T13:59', now)).toBe(true);
|
||||||
|
expect(isOverdue('2026-10-29T14:30', now)).toBe(false);
|
||||||
|
expect(isOverdue('2026-10-29', now)).toBe(false);
|
||||||
|
expect(isOverdue('2026-10-28', now)).toBe(true);
|
||||||
|
expect(isDueToday('2026-10-29T08:00', now)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Due dates come as "YYYY-MM-DD" (all day) or "YYYY-MM-DDTHH:MM" (with time), in the server's time zone.
|
||||||
|
// Both sort correctly as plain strings.
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/** Local calendar day as YYYY-MM-DD (toISOString would give the UTC day). */
|
||||||
|
export const localDay = (d: Date = new Date()): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
|
||||||
|
export const addDays = (days: number, from: Date = new Date()): string => {
|
||||||
|
const d = new Date(from);
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return localDay(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const splitDue = (due?: string): { date: string; time: string } => {
|
||||||
|
if (!due) return { date: '', time: '' };
|
||||||
|
const [date, time = ''] = due.split('T');
|
||||||
|
return { date, time: time.slice(0, 5) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const joinDue = (date: string, time: string): string => (date ? (time ? `${date}T${time}` : date) : '');
|
||||||
|
|
||||||
|
/** A task with a time is overdue once that minute has passed, an all-day task from the next day on. */
|
||||||
|
export const isOverdue = (due: string | undefined, now: Date = new Date()): boolean => {
|
||||||
|
if (!due) return false;
|
||||||
|
const { date, time } = splitDue(due);
|
||||||
|
const today = localDay(now);
|
||||||
|
if (date !== today) return date < today;
|
||||||
|
return !!time && time < `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isDueToday = (due: string | undefined, now: Date = new Date()): boolean =>
|
||||||
|
!!due && splitDue(due).date === localDay(now);
|
||||||
|
|
||||||
|
const WEEKDAYS = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'];
|
||||||
|
|
||||||
|
/** Short label: "Heute, 14:30", "Morgen", "Gestern", "Fr., 30.10." or "30.10.2027" in another year. */
|
||||||
|
export const formatDue = (due: string | undefined, now: Date = new Date()): string => {
|
||||||
|
if (!due) return '';
|
||||||
|
const { date, time } = splitDue(due);
|
||||||
|
const [y, m, d] = date.split('-').map(Number);
|
||||||
|
if (!y || !m || !d) return due;
|
||||||
|
let label: string;
|
||||||
|
if (date === localDay(now)) label = 'Heute';
|
||||||
|
else if (date === addDays(1, now)) label = 'Morgen';
|
||||||
|
else if (date === addDays(-1, now)) label = 'Gestern';
|
||||||
|
else if (y === now.getFullYear()) label = `${WEEKDAYS[new Date(y, m - 1, d).getDay()]}, ${pad(d)}.${pad(m)}.`;
|
||||||
|
else label = `${pad(d)}.${pad(m)}.${y}`;
|
||||||
|
return time ? `${label}, ${time}` : label;
|
||||||
|
};
|
||||||
@@ -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
|
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() {
|
func (s *Server) startAlarmTicker() {
|
||||||
ticker := time.NewTicker(60 * time.Second)
|
ticker := time.NewTicker(60 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -3209,10 +3216,10 @@ func (s *Server) startAlarmTicker() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
today := time.Now().Format("2006-01-02")
|
now := time.Now()
|
||||||
|
|
||||||
for _, a := range alarms {
|
for _, a := range alarms {
|
||||||
if a.DueDate > today {
|
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(a.AlarmChannels) == 0 {
|
if len(a.AlarmChannels) == 0 {
|
||||||
@@ -3220,6 +3227,7 @@ func (s *Server) startAlarmTicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sentAny := false
|
sentAny := false
|
||||||
|
due := domain.HumanDue(a.DueDate)
|
||||||
desc := a.Notes
|
desc := a.Notes
|
||||||
if desc == "" {
|
if desc == "" {
|
||||||
desc = "Aufgabe ist jetzt fällig."
|
desc = "Aufgabe ist jetzt fällig."
|
||||||
@@ -3229,7 +3237,7 @@ func (s *Server) startAlarmTicker() {
|
|||||||
switch ch {
|
switch ch {
|
||||||
case "discord":
|
case "discord":
|
||||||
if a.DiscordWebhookURL != "" {
|
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
|
sentAny = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3241,7 +3249,7 @@ func (s *Server) startAlarmTicker() {
|
|||||||
}
|
}
|
||||||
case "email":
|
case "email":
|
||||||
if a.AlarmEmail != "" {
|
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 {
|
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
|
||||||
sentAny = true
|
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
|
line += " 🔁 every " + every
|
||||||
}
|
}
|
||||||
if t.DueDate != "" {
|
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
|
var sb strings.Builder
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
recurRe = regexp.MustCompile(`(?i)[ \t]*🔁[ \t]*every[ \t]+(day|week|month|year)\b`)
|
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]*(🔺|⏫|🔼|🔽|⏬)`)
|
priorityRe = regexp.MustCompile(`[ \t]*(🔺|⏫|🔼|🔽|⏬)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -801,6 +801,9 @@ func parseTaskFile(body string) []importTask {
|
|||||||
}
|
}
|
||||||
if d := dueRe.FindStringSubmatch(t.content); d != nil {
|
if d := dueRe.FindStringSubmatch(t.content); d != nil {
|
||||||
t.due = d[1]
|
t.due = d[1]
|
||||||
|
if d[2] != "" {
|
||||||
|
t.due += "T" + d[2]
|
||||||
|
}
|
||||||
t.content = dueRe.ReplaceAllString(t.content, "")
|
t.content = dueRe.ReplaceAllString(t.content, "")
|
||||||
}
|
}
|
||||||
if p := priorityRe.FindStringSubmatch(t.content); p != nil {
|
if p := priorityRe.FindStringSubmatch(t.content); p != nil {
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ func validRecurrence(r string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// NextDueDate moves a due date (YYYY-MM-DD, or today when empty) one interval forward. Months and years
|
// NextDueDate moves a due date (YYYY-MM-DD or YYYY-MM-DDTHH:MM, today when empty) one interval forward
|
||||||
// keep the day where possible and fall back to the month's last day (31 Jan -> 28/29 Feb).
|
// 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 {
|
func NextDueDate(due, rule string, today time.Time) string {
|
||||||
base, err := time.Parse("2006-01-02", due)
|
parsed, hasTime, ok := domain.ParseDue(due, time.UTC)
|
||||||
if err != nil {
|
base := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, 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 {
|
switch rule {
|
||||||
case "daily":
|
case "daily":
|
||||||
@@ -35,6 +37,9 @@ func NextDueDate(due, rule string, today time.Time) string {
|
|||||||
case "yearly":
|
case "yearly":
|
||||||
base = addMonthsClamped(base, 12)
|
base = addMonthsClamped(base, 12)
|
||||||
}
|
}
|
||||||
|
if hasTime {
|
||||||
|
return base.Format("2006-01-02") + parsed.Format("T15:04")
|
||||||
|
}
|
||||||
return base.Format("2006-01-02")
|
return base.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ func TestNextDueDate(t *testing.T) {
|
|||||||
{"2026-03-15", "monthly", "2026-04-15"},
|
{"2026-03-15", "monthly", "2026-04-15"},
|
||||||
{"2028-02-29", "yearly", "2029-02-28"},
|
{"2028-02-29", "yearly", "2029-02-28"},
|
||||||
{"", "weekly", "2026-11-05"},
|
{"", "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 {
|
for _, c := range cases {
|
||||||
if got := NextDueDate(c.due, c.rule, today); got != c.want {
|
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")
|
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) {
|
func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) {
|
||||||
content = strings.TrimSpace(content)
|
content = strings.TrimSpace(content)
|
||||||
|
dueDate = domain.NormalizeDue(dueDate)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return nil, errors.New("content cannot be empty")
|
return nil, errors.New("content cannot be empty")
|
||||||
}
|
}
|
||||||
@@ -1098,8 +1099,9 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
|
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
|
||||||
it.SortOrder, *it.KanbanStatus, isCompleted, it.ID,
|
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`,
|
||||||
|
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
@@ -1132,6 +1134,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
newStatus := domain.StatusKept
|
newStatus := domain.StatusKept
|
||||||
convertedType := thought.ConvertedType
|
convertedType := thought.ConvertedType
|
||||||
convertedRefID := thought.ConvertedRefID
|
convertedRefID := thought.ConvertedRefID
|
||||||
|
dueDate = domain.NormalizeDue(dueDate)
|
||||||
if dueDate == "" {
|
if dueDate == "" {
|
||||||
dueDate = thought.DueDate
|
dueDate = thought.DueDate
|
||||||
}
|
}
|
||||||
@@ -1194,7 +1197,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
}
|
}
|
||||||
var metaItems []string
|
var metaItems []string
|
||||||
if thought.DueDate != "" {
|
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" {
|
if thought.Priority != "" && thought.Priority != "normal" {
|
||||||
metaItems = append(metaItems, "Priorität: "+thought.Priority)
|
metaItems = append(metaItems, "Priorität: "+thought.Priority)
|
||||||
@@ -1233,9 +1236,10 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
|
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
`UPDATE thoughts
|
`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 = ?`,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1253,6 +1257,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
wasCompleted := thought.IsCompleted
|
wasCompleted := thought.IsCompleted
|
||||||
|
oldDue := thought.DueDate
|
||||||
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1283,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if dueDate != nil {
|
if dueDate != nil {
|
||||||
thought.DueDate = *dueDate
|
thought.DueDate = domain.NormalizeDue(*dueDate)
|
||||||
}
|
}
|
||||||
if priority != nil {
|
if priority != nil {
|
||||||
p := strings.ToLower(strings.TrimSpace(*priority))
|
p := strings.ToLower(strings.TrimSpace(*priority))
|
||||||
@@ -1321,6 +1326,11 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if taskListID != nil {
|
||||||
s.PruneTaskAssignees()
|
s.PruneTaskAssignees()
|
||||||
thought.Assignees = s.getTaskAssignees(thoughtID)
|
thought.Assignees = s.getTaskAssignees(thoughtID)
|
||||||
@@ -3967,7 +3977,8 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
|||||||
doneInt = 1
|
doneInt = 1
|
||||||
kanban = "done"
|
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 checked && res != nil {
|
||||||
if n, _ := res.RowsAffected(); n > 0 {
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
s.spawnNextOccurrence(taskID)
|
s.spawnNextOccurrence(taskID)
|
||||||
|
|||||||
Reference in new issue
Block a user