feat(phase3): reminders, ICS feeds, templates and nightly backups

Reminders are absolute or relative ("10 minutes before"). A relative one
is resolved against the task in SQL, so moving a task moves its reminders
too. They are marked sent before delivery, not after: a reminder that
goes out unrecorded repeats every minute forever, which is worse than
one recorded that fails to send.

The calendar feed is a per-user token in the path, because Apple Calendar
and Google fetch with no credentials and no way to prompt for any. Tasks
go out as VTODO — the same component CalDAV will serve — and timed ones
also as VEVENT, since Google ignores VTODO entirely.

Templates store relative day offsets rather than dates, which is what
makes them reusable: an onboarding checklist is "day 1, day 3, day 14",
not three dates in March. Building a project from one is a single
transaction, so a failure cannot leave half a checklist.

Backups use SQLite's VACUUM INTO, not a file copy — a copy of a live
WAL database can miss recent writes or catch a page mid-update, and the
result looks like a database right up until it is needed. Rotation keeps
fourteen by count, not by age: a container that was off for a month must
not come back and delete every backup it has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 07:05:24 +02:00
co-authored by Claude Opus 5
parent 4d142b2fa7
commit 7e4c730049
13 changed files with 2315 additions and 1 deletions
+17 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/httpapi"
"github.com/kristianwind/verdande/internal/jobs"
"github.com/kristianwind/verdande/internal/store"
)
@@ -53,9 +54,19 @@ func run() error {
defer db.Close()
log.Info("database ready", "path", db.Path())
api := httpapi.New(cfg, db, log, webAssets(log))
// Background work: reminders, the nightly backup, and the trash emptying
// itself. Started before the listener so a reminder that came due while the
// process was down goes out immediately rather than on the next tick.
jobCtx, stopJobs := context.WithCancel(context.Background())
defer stopJobs()
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
runner.Start(jobCtx)
srv := &http.Server{
Addr: cfg.Addr,
Handler: httpapi.New(cfg, db, log, webAssets(log)),
Handler: api,
ReadHeaderTimeout: 10 * time.Second,
// No WriteTimeout: it would also cap the WebSocket and any long file
// download. Per-handler timeouts do that job with the right granularity.
@@ -89,6 +100,11 @@ func run() error {
return fmt.Errorf("shutdown: %w", err)
}
// Let the background jobs finish the pass they are in. A shutdown during a
// backup would otherwise leave a partial file that looks like a backup.
stopJobs()
runner.Wait()
// WAL mode leaves recent writes in the -wal file. Checkpointing on the way out
// means a backup taken of a stopped container is complete.
if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {