Add push reminders for sleep, pee, poo and meals

A closed PWA has no timers, so reminders are evaluated on the server: the
event log is already there (clients sync on every mutation), and a ticker
re-checks each enabled rule once a minute and pushes the ones that are due.

Two rule shapes. "sleep" measures from the last sleep-end and fires only
while the puppy is awake. "pee"/"poo"/"eat" measure from the newest event of
that type and stay quiet while the puppy is asleep — otherwise they nag all
night, and suppressing them means an overdue rule instead fires promptly on
waking, which is when it actually matters. Sleep state is derived exactly the
way currentSleepState() does in app.js, tie-break included, so both sides
always agree. Rules read the event's own timestamp rather than when it synced,
so a pee logged offline at 03:10 cancels the reminder retroactively.

Every push carries a tag, so a repeat replaces the previous notification
instead of stacking another one on the lock screen. last_fired is server-owned
and not writable by a client, so a stale device can't force a re-fire.

Web Push is implemented directly rather than pulled in as a dependency: RFC
8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token,
stdlib only, checked against the RFC 8291 test vector. The key is generated
into vapid.json beside the DB or supplied via -vapid-key; without one the
server logs a warning, skips registering the routes, and the client hides the
UI. Subscriptions a push service reports as 404/410 are dropped.

PNG icons are added because iOS gates push on a Home Screen install and
rejects SVG for apple-touch-icon, and Android has no notification icon
without them.
This commit is contained in:
Alexander Heldt
2026-08-20 17:19:18 +00:00
parent 93d6ea27a7
commit 51d015c231
17 changed files with 1918 additions and 11 deletions
+43 -2
View File
@@ -336,6 +336,23 @@ func openDB(path string) (*sql.DB, error) {
nodes TEXT NOT NULL DEFAULT '',
generations INTEGER NOT NULL DEFAULT 0,
fetched INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT '',
p256dh TEXT NOT NULL DEFAULT '',
auth TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_subs_user ON push_subscriptions(user_id);
CREATE TABLE IF NOT EXISTS reminders (
user_id TEXT NOT NULL,
kind TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
interval_min INTEGER NOT NULL DEFAULT 0,
last_fired INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, kind)
);`
if _, err := db.Exec(schema); err != nil {
db.Close()
@@ -561,8 +578,9 @@ func newSWVersion(dir string) *swVersion {
// itself is excluded: it carries the placeholder, so hashing it would be
// circular and it never changes except when we edit it here.
return &swVersion{
dir: dir,
files: []string{"index.html", "style.css", "app.js", "manifest.json", "icon.svg", "changelog.json"},
dir: dir,
files: []string{"index.html", "style.css", "app.js", "manifest.json", "icon.svg",
"icon-180.png", "icon-192.png", "icon-512.png", "changelog.json"},
}
}
@@ -618,6 +636,8 @@ func main() {
"shared secret required to register (env PUPPY_INVITE_CODE); empty disables registration")
secureCookies := flag.Bool("secure-cookies", false,
"mark session cookies Secure (enable when served over HTTPS / behind a TLS proxy)")
vapidKey := flag.String("vapid-key", os.Getenv("PUPPY_VAPID_KEY"),
"base64url P-256 private key for Web Push (env PUPPY_VAPID_KEY); generated next to the DB when unset")
flag.Parse()
db, err := openDB(*dataPath)
@@ -641,6 +661,16 @@ func main() {
log.Fatalf("mkdir photos: %v", err)
}
// Reminders are optional: if the push identity cannot be established the rest
// of the app must still come up, just without notifications.
var scheduler *Scheduler
if key, err := loadVAPIDKey(*vapidKey, filepath.Join(filepath.Dir(*dataPath), "vapid.json")); err != nil {
log.Printf("WARNING: push reminders disabled: %v", err)
} else {
scheduler = newScheduler(db, newSubscriptionStore(db), newReminderStore(db), key)
go scheduler.run(reminderTick)
}
auth := newAuth(db, *inviteCode, *secureCookies, photosDir)
if *inviteCode == "" {
log.Print("WARNING: no invite code set — registration is disabled (set -invite-code / PUPPY_INVITE_CODE)")
@@ -768,6 +798,17 @@ func main() {
pedigrees.handleStatus(w, r)
}))
// Push reminders. Registered only when the scheduler came up, so a server
// without a usable VAPID key 404s these rather than half-working — which is
// also what tells the client to hide the reminder UI entirely.
if scheduler != nil {
mux.HandleFunc("/api/push/key", auth.requireUser(scheduler.handleKey))
mux.HandleFunc("/api/push/subscribe", auth.requireUser(scheduler.handleSubscribe))
mux.HandleFunc("/api/push/unsubscribe", auth.requireUser(scheduler.handleUnsubscribe))
mux.HandleFunc("/api/push/test", auth.requireUser(scheduler.handleTest))
mux.HandleFunc("/api/reminders", auth.requireUser(scheduler.handleReminders))
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})