Files
puppy-tracker/server/reminders.go
T
Alexander Heldt 51d015c231 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.
2026-08-20 17:19:18 +00:00

606 lines
18 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"time"
)
// Reminders are evaluated on the server because that is the only place that can
// act while every client is closed — a PWA gets no timers of its own once the
// tab is gone. The event log is already here (clients sync on every mutation),
// so a rule is just a query over it plus a push.
//
// Two shapes of rule, both keyed off the same synced events:
//
// - "sleep": the puppy has been awake too long. Fires only while awake, so it
// is silent overnight by construction.
// - "pee" / "poo" / "eat": nothing of that type logged for too long. Suppressed
// while the puppy is asleep — otherwise it nags all night — which also means
// it fires promptly on waking if it was already overdue, matching how a puppy
// actually behaves.
// reminderKinds are the rules a user can enable, with the interval each starts
// at. Order is the order they appear in Settings.
var reminderKinds = []struct {
Kind string
Default int // minutes
}{
{"sleep", 45},
{"pee", 60},
{"poo", 180},
{"eat", 240},
}
func defaultInterval(kind string) (int, bool) {
for _, k := range reminderKinds {
if k.Kind == kind {
return k.Default, true
}
}
return 0, false
}
// Reminder is one rule as the client sees it. LastFired is server-owned state
// and deliberately absent: a client PUT must never be able to reset it, or a
// device with a stale copy could make a rule re-fire immediately.
type Reminder struct {
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
IntervalMin int `json:"intervalMin"`
}
type ReminderStore struct {
db *sql.DB
}
func newReminderStore(db *sql.DB) *ReminderStore { return &ReminderStore{db: db} }
// get returns every rule for a user, filling in defaults for kinds they have
// never touched so the client always renders the full set.
func (rs *ReminderStore) get(userID string) ([]Reminder, error) {
rows, err := rs.db.Query(
`SELECT kind, enabled, interval_min FROM reminders WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
stored := map[string]Reminder{}
for rows.Next() {
var r Reminder
if err := rows.Scan(&r.Kind, &r.Enabled, &r.IntervalMin); err != nil {
return nil, err
}
stored[r.Kind] = r
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Reminder, 0, len(reminderKinds))
for _, k := range reminderKinds {
if r, ok := stored[k.Kind]; ok {
out = append(out, r)
continue
}
out = append(out, Reminder{Kind: k.Kind, Enabled: false, IntervalMin: k.Default})
}
return out, nil
}
// put writes the rules a client sent. Unknown kinds are ignored and intervals
// are clamped, so a bad client can't install a rule that fires every minute.
// Enabling a rule clears last_fired so it can alert immediately if already due,
// rather than waiting out an interval from whenever it last ran.
func (rs *ReminderStore) put(userID string, in []Reminder) error {
tx, err := rs.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, r := range in {
if _, ok := defaultInterval(r.Kind); !ok {
continue
}
if r.IntervalMin < 5 {
r.IntervalMin = 5
}
if r.IntervalMin > 24*60 {
r.IntervalMin = 24 * 60
}
if _, err := tx.Exec(`
INSERT INTO reminders (user_id, kind, enabled, interval_min, last_fired, updated)
VALUES (?, ?, ?, ?, 0, ?)
ON CONFLICT(user_id, kind) DO UPDATE SET
enabled = excluded.enabled,
interval_min = excluded.interval_min,
updated = excluded.updated,
last_fired = CASE WHEN reminders.enabled = 0 AND excluded.enabled = 1
THEN 0 ELSE reminders.last_fired END`,
userID, r.Kind, r.Enabled, r.IntervalMin, time.Now().UnixMilli()); err != nil {
return err
}
}
return tx.Commit()
}
// ---------- subscriptions ----------
type SubscriptionStore struct {
db *sql.DB
}
func newSubscriptionStore(db *sql.DB) *SubscriptionStore { return &SubscriptionStore{db: db} }
// save records a device's push subscription. The endpoint is the primary key:
// browsers reuse it across sessions, and re-subscribing (which iOS forces
// regularly) must update the existing row rather than accumulate dead ones.
func (ss *SubscriptionStore) save(userID string, sub Subscription) error {
_, err := ss.db.Exec(`
INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, created)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth`,
sub.Endpoint, userID, sub.Keys.P256dh, sub.Keys.Auth, time.Now().UnixMilli())
return err
}
func (ss *SubscriptionStore) delete(endpoint string) error {
_, err := ss.db.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
return err
}
func (ss *SubscriptionStore) forUser(userID string) ([]Subscription, error) {
rows, err := ss.db.Query(
`SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Subscription
for rows.Next() {
var s Subscription
if err := rows.Scan(&s.Endpoint, &s.Keys.P256dh, &s.Keys.Auth); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ---------- evaluation ----------
// sleepState mirrors currentSleepState() in app.js: the latest sleep boundary
// decides whether the puppy is awake, with an equal-timestamp tie broken by the
// later-written event so both sides agree on the same answer.
type sleepState struct {
state string // "asleep", "awake", or "" when nothing has ever been logged
since int64 // ms epoch of that boundary
}
func (sch *Scheduler) sleepStateFor(userID string) (sleepState, error) {
var typ string
var at int64
err := sch.db.QueryRow(`
SELECT type, at FROM events
WHERE user_id = ? AND deleted = 0 AND type IN ('sleep-start', 'sleep-end')
ORDER BY at DESC, updated DESC LIMIT 1`, userID).Scan(&typ, &at)
if errors.Is(err, sql.ErrNoRows) {
return sleepState{}, nil
}
if err != nil {
return sleepState{}, err
}
if typ == "sleep-start" {
return sleepState{state: "asleep", since: at}, nil
}
return sleepState{state: "awake", since: at}, nil
}
// lastEventAt is the timestamp of the newest surviving event of a type, or 0
// when there is none.
func (sch *Scheduler) lastEventAt(userID, typ string) (int64, error) {
var at sql.NullInt64
err := sch.db.QueryRow(
`SELECT MAX(at) FROM events WHERE user_id = ? AND type = ? AND deleted = 0`,
userID, typ).Scan(&at)
if err != nil {
return 0, err
}
return at.Int64, nil
}
// notification is the payload the service worker receives. Tag is what makes a
// repeat fire replace the previous notification instead of stacking a new one.
type notification struct {
Title string `json:"title"`
Body string `json:"body"`
Tag string `json:"tag"`
URL string `json:"url"`
}
// due decides whether a rule should fire right now, and with what text. The
// returned notification is only meaningful when due is true.
func (sch *Scheduler) due(userID string, r Reminder, lastFired, now int64, sleep sleepState) (notification, bool, error) {
interval := int64(r.IntervalMin) * 60 * 1000
var since int64
var title, body string
if r.Kind == "sleep" {
// Only meaningful while awake; asleep means the rule has been satisfied.
if sleep.state != "awake" {
return notification{}, false, nil
}
since = sleep.since
title = "Time to sleep"
body = "Awake for " + humanDuration(now-since)
} else {
// A sleeping puppy isn't going to pee, eat, or poo — stay quiet until it
// wakes, at which point an already-overdue rule fires on the next tick.
if sleep.state == "asleep" {
return notification{}, false, nil
}
at, err := sch.lastEventAt(userID, r.Kind)
if err != nil {
return notification{}, false, err
}
if at == 0 {
// Nothing logged yet, so there is no clock to run.
return notification{}, false, nil
}
since = at
title, body = reminderText(r.Kind, now-since)
}
if now-since < interval {
return notification{}, false, nil
}
// Once overdue, repeat at the rule's own interval rather than every tick.
if lastFired != 0 && now-lastFired < interval {
return notification{}, false, nil
}
return notification{
Title: title,
Body: body,
Tag: "reminder:" + r.Kind,
URL: "./",
}, true, nil
}
func reminderText(kind string, elapsed int64) (title, body string) {
switch kind {
case "pee":
return "Time for pee", "No pee logged for " + humanDuration(elapsed)
case "poo":
return "Time for poo", "No poo logged for " + humanDuration(elapsed)
case "eat":
return "Time for a meal", "No meal logged for " + humanDuration(elapsed)
}
return "Reminder", "Nothing logged for " + humanDuration(elapsed)
}
// humanDuration renders an elapsed span the way the notification body reads it:
// "45 min", "1 h 12 min", "2 h".
func humanDuration(ms int64) string {
if ms < 0 {
ms = 0
}
mins := ms / 60000
h, m := mins/60, mins%60
switch {
case h == 0:
return fmt.Sprintf("%d min", m)
case m == 0:
return fmt.Sprintf("%d h", h)
default:
return fmt.Sprintf("%d h %d min", h, m)
}
}
// ---------- scheduler ----------
// Scheduler evaluates every enabled rule once a minute and pushes the ones that
// have come due. One goroutine for the whole server: the work per tick is a
// couple of indexed queries per user with a rule switched on.
type Scheduler struct {
db *sql.DB
subs *SubscriptionStore
rules *ReminderStore
key *VAPIDKey
client *http.Client
}
// reminderTick is how often every enabled rule is re-evaluated. A minute is well
// under the shortest interval a rule can be set to, so a reminder never lands
// more than a minute late.
const reminderTick = time.Minute
func newScheduler(db *sql.DB, subs *SubscriptionStore, rules *ReminderStore, key *VAPIDKey) *Scheduler {
return &Scheduler{
db: db,
subs: subs,
rules: rules,
key: key,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (sch *Scheduler) run(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if err := sch.tick(time.Now().UnixMilli()); err != nil {
log.Printf("reminder tick: %v", err)
}
}
}
// tick evaluates all rules once. now is a parameter so tests can drive it.
func (sch *Scheduler) tick(now int64) error {
// Only users who both switched a rule on and have somewhere to push to.
rows, err := sch.db.Query(`
SELECT DISTINCT r.user_id FROM reminders r
WHERE r.enabled = 1
AND EXISTS (SELECT 1 FROM push_subscriptions s WHERE s.user_id = r.user_id)`)
if err != nil {
return err
}
var users []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
users = append(users, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
sort.Strings(users)
for _, uid := range users {
if err := sch.tickUser(uid, now); err != nil {
log.Printf("reminders for %s: %v", uid, err)
}
}
return nil
}
func (sch *Scheduler) tickUser(userID string, now int64) error {
sleep, err := sch.sleepStateFor(userID)
if err != nil {
return err
}
rows, err := sch.db.Query(
`SELECT kind, enabled, interval_min, last_fired FROM reminders
WHERE user_id = ? AND enabled = 1`, userID)
if err != nil {
return err
}
type pending struct {
rule Reminder
note notification
}
var fire []pending
for rows.Next() {
var r Reminder
var lastFired int64
if err := rows.Scan(&r.Kind, &r.Enabled, &r.IntervalMin, &lastFired); err != nil {
rows.Close()
return err
}
note, ok, err := sch.due(userID, r, lastFired, now, sleep)
if err != nil {
rows.Close()
return err
}
if ok {
fire = append(fire, pending{rule: r, note: note})
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(fire) == 0 {
return nil
}
subs, err := sch.subs.forUser(userID)
if err != nil {
return err
}
for _, p := range fire {
// Stamp the fire before sending: a push service that is slow or briefly
// erroring must not cause the same reminder to be retried every tick.
if _, err := sch.db.Exec(
`UPDATE reminders SET last_fired = ? WHERE user_id = ? AND kind = ?`,
now, userID, p.rule.Kind); err != nil {
return err
}
sch.broadcast(subs, p.note)
}
return nil
}
// broadcast sends one notification to every device the user has registered,
// pruning any subscription the push service reports as permanently gone.
func (sch *Scheduler) broadcast(subs []Subscription, note notification) {
payload, err := json.Marshal(note)
if err != nil {
log.Printf("marshal notification: %v", err)
return
}
for _, sub := range subs {
if err := sch.key.send(sch.client, sub, payload, 3600); err != nil {
var pe *pushError
if errors.As(err, &pe) && pe.Gone {
if delErr := sch.subs.delete(sub.Endpoint); delErr != nil {
log.Printf("drop dead subscription: %v", delErr)
}
continue
}
log.Printf("push send: %v", err)
}
}
}
// ---------- handlers ----------
// handleKey hands the client the VAPID public key it must pass as
// applicationServerKey when subscribing. Public by design — it only identifies
// this server; the private half never leaves it.
func (sch *Scheduler) handleKey(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{"key": sch.key.Public})
}
// handleSubscribe stores (or refreshes) the calling device's subscription.
// Clients re-post on every launch, because iOS quietly drops subscriptions and
// a stale one would silently stop receiving.
func (sch *Scheduler) handleSubscribe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var sub Subscription
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&sub); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := validSubscription(sub); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := sch.subs.save(userID(r), sub); err != nil {
log.Printf("save subscription: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnsubscribe drops one endpoint. Scoped to the caller so one account
// can't delete another's device.
func (sch *Scheduler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if _, err := sch.db.Exec(
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`,
body.Endpoint, userID(r)); err != nil {
log.Printf("delete subscription: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleTest pushes one notification to the caller's devices. Push failures are
// invisible from the browser side — especially on iOS — so this is the only
// practical way to tell "not subscribed" apart from "subscribed but undelivered".
func (sch *Scheduler) handleTest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
subs, err := sch.subs.forUser(userID(r))
if err != nil {
log.Printf("test push: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if len(subs) == 0 {
http.Error(w, "no subscriptions", http.StatusNotFound)
return
}
sch.broadcast(subs, notification{
Title: "Reminders are on",
Body: "This is what a reminder looks like.",
Tag: "reminder:test",
URL: "./",
})
w.WriteHeader(http.StatusNoContent)
}
// handleReminders reads and writes the caller's rules.
func (sch *Scheduler) handleReminders(w http.ResponseWriter, r *http.Request) {
uid := userID(r)
switch r.Method {
case http.MethodGet:
case http.MethodPut:
var body struct {
Reminders []Reminder `json:"reminders"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := sch.rules.put(uid, body.Reminders); err != nil {
log.Printf("put reminders: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Both verbs answer with the stored set, so a PUT tells the client exactly
// what was kept after clamping.
list, err := sch.rules.get(uid)
if err != nil {
log.Printf("get reminders: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"reminders": list})
}
// validSubscription rejects anything we could not push to later, so a bad row
// never reaches the scheduler. The key sizes are fixed by RFC 8291: an
// uncompressed P-256 point and a 16-byte auth secret.
func validSubscription(sub Subscription) error {
u, err := url.Parse(sub.Endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" {
return errors.New("endpoint must be an https URL")
}
if len(sub.Endpoint) > 2048 {
return errors.New("endpoint too long")
}
p256dh, err := b64.DecodeString(sub.Keys.P256dh)
if err != nil || len(p256dh) != 65 || p256dh[0] != 4 {
return errors.New("bad p256dh key")
}
auth, err := b64.DecodeString(sub.Keys.Auth)
if err != nil || len(auth) != 16 {
return errors.New("bad auth secret")
}
return nil
}