M6 1908a: recurring reminders + complete/snooze (backend, no web-push)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 32s

Per operator: skip Web Push; build the rest of reminder delivery. This is
the client-agnostic half — the model + logic that foreground/native
delivery drives.

- notes.recurrence (migration 0022): daily/weekly/monthly/yearly or null.
  update_note accepts it (cleared when the reminder is cleared); rides
  export/import + sync push. Serialized on the note.
- Pure next_occurrence(remind_at, recurrence, after): the next fire strictly
  after `after`, rolling past missed occurrences; _add_months clamps the day
  to the target month (Jan 31 → Feb 28).
- POST /api/notes/<id>/reminder/complete — a recurring reminder advances to
  its next occurrence; a one-off clears. POST .../reminder/snooze {minutes}
  → remind_at = now + minutes (1 min .. 30 days).

No VAPID / push-subscription / service-worker — foreground + native delivery
land in the UI commit and the native clients.

Tests (DB-free): normalize_recurrence; next_occurrence (daily/weekly/
monthly-clamp/skip-missed/yearly/none); complete + snooze auth-guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-23 11:53:07 -04:00
co-authored by Claude Opus 4.8
parent 5c045aed63
commit 882d4206aa
5 changed files with 175 additions and 2 deletions
+24
View File
@@ -0,0 +1,24 @@
"""notes.recurrence (recurring reminders — M6 1908)
Revision ID: 0022
Revises: 0021
Create Date: 2026-07-23
Optional recurrence for a note's reminder (daily/weekly/monthly/yearly). On
"complete", a recurring reminder advances remind_at to its next occurrence.
"""
from alembic import op
import sqlalchemy as sa
revision = "0022"
down_revision = "0021"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("recurrence", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("notes", "recurrence")
+5 -1
View File
@@ -53,8 +53,11 @@ class Note(Base):
archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
# Soft delete: non-null => in Trash. Restore sets it back to null.
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Optional reminder time (surfaced in the Reminders view; no push in M3).
# Optional reminder time (surfaced in the Reminders view + foreground delivery).
remind_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Optional recurrence for the reminder: daily | weekly | monthly | yearly (else null).
# On "complete", a recurring reminder advances remind_at to its next occurrence.
recurrence: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
@@ -80,6 +83,7 @@ class Note(Base):
"archived": self.archived,
"trashed": self.deleted_at is not None,
"remind_at": self.remind_at.isoformat() if self.remind_at else None,
"recurrence": self.recurrence,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+89 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import calendar
import hashlib
import io
import json
@@ -8,7 +9,7 @@ import posixpath
import re
import uuid
import zipfile
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from quart import Blueprint, Response, g, jsonify, request, send_file
from sqlalchemy import case, delete, func, literal_column, select
@@ -325,6 +326,49 @@ def _parse_iso_dt(raw: str) -> datetime:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
REMINDER_RECURRENCES = {"daily", "weekly", "monthly", "yearly"}
def normalize_recurrence(value: object) -> str | None:
return value if value in REMINDER_RECURRENCES else None
def _add_months(dt: datetime, months: int) -> datetime:
"""Shift a datetime by whole months, clamping the day to the target month's length
(so Jan 31 + 1 month → Feb 28/29). Keeps the time-of-day."""
m = dt.month - 1 + months
year = dt.year + m // 12
month = m % 12 + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def _advance_once(dt: datetime, recurrence: str) -> datetime | None:
if recurrence == "daily":
return dt + timedelta(days=1)
if recurrence == "weekly":
return dt + timedelta(weeks=1)
if recurrence == "monthly":
return _add_months(dt, 1)
if recurrence == "yearly":
return _add_months(dt, 12)
return None
def next_occurrence(remind_at: datetime, recurrence: str, after: datetime) -> datetime | None:
"""The next reminder fire time strictly after `after`, rolling a recurring reminder
forward past any missed occurrences. None if `recurrence` isn't a known interval."""
nxt = _advance_once(remind_at, recurrence)
if nxt is None:
return None
while nxt <= after:
step = _advance_once(nxt, recurrence)
if step is None or step == nxt:
break
nxt = step
return nxt
def _truthy(raw: str | None) -> bool:
return raw in ("true", "1", "yes", "on")
@@ -439,6 +483,43 @@ async def list_reminders():
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.post("/<note_id>/reminder/complete")
@login_required
async def complete_reminder(note_id: str):
"""Mark a reminder handled: a recurring reminder advances to its next occurrence;
a one-off clears its reminder."""
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return jsonify({"error": "not found"}), 404
if note.remind_at is not None and note.recurrence in REMINDER_RECURRENCES:
note.remind_at = next_occurrence(note.remind_at, note.recurrence, datetime.now(timezone.utc))
else:
note.remind_at = None
note.recurrence = None
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/reminder/snooze")
@login_required
async def snooze_reminder(note_id: str):
"""Re-fire a reminder a little later — remind_at moves to now + `minutes`."""
data = await request.get_json(silent=True) or {}
try:
minutes = int(data.get("minutes", 10))
except (ValueError, TypeError):
minutes = 10
minutes = max(1, min(minutes, 60 * 24 * 30)) # 1 minute .. 30 days
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return jsonify({"error": "not found"}), 404
note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
await db.commit()
return jsonify(await _serialize_note(db, note))
def _slugify(text: str) -> str:
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
@@ -523,6 +604,7 @@ async def export_notes():
"pinned": n.pinned,
"archived": n.archived,
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
"recurrence": n.recurrence,
"created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
"labels": [lb["name"] for lb in labels],
@@ -604,6 +686,7 @@ def _native_spec(n: dict) -> dict:
"archived": bool(n.get("archived")),
"trashed": False, # export only includes live notes
"remind_at": _iso_to_dt(n.get("remind_at")),
"recurrence": normalize_recurrence(n.get("recurrence")),
"created_at": _iso_to_dt(n.get("created_at")),
"updated_at": _iso_to_dt(n.get("updated_at")),
"labels": [s for s in (n.get("labels") or []) if isinstance(s, str)],
@@ -762,6 +845,8 @@ async def _create_imported_note(db, owner_id, spec: dict, zf: zipfile.ZipFile, p
)
if spec.get("remind_at"):
note.remind_at = spec["remind_at"]
if spec.get("recurrence"):
note.recurrence = spec["recurrence"]
if spec.get("trashed"):
note.deleted_at = datetime.now(timezone.utc)
# Preserve source timestamps: set before flush so they land in the INSERT
@@ -1046,11 +1131,14 @@ async def update_note(note_id: str):
raw = data["remind_at"]
if raw in (None, ""):
note.remind_at = None
note.recurrence = None # no reminder → recurrence is moot
else:
try:
note.remind_at = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except ValueError:
return jsonify({"error": "invalid remind_at"}), 400
if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"])
# Recompute the display name (explicit title, else first body line) whenever
# the title or body may have changed.
if "title" in data or "body" in data:
+2
View File
@@ -34,6 +34,7 @@ from .notes import (
_serialize_notes,
derive_display_title,
normalize_color,
normalize_recurrence,
)
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
@@ -178,6 +179,7 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
else:
note.deleted_at = None
note.remind_at = _parse_client_dt(ch.get("remind_at"))
note.recurrence = normalize_recurrence(ch.get("recurrence"))
if isinstance(ch.get("position"), int):
note.position = ch["position"]
+55
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
import pytest
from thoughtsync.app import create_app
@@ -15,7 +17,9 @@ from thoughtsync.notes import (
_usec_to_dt,
derive_display_title,
is_empty_note,
next_occurrence,
normalize_color,
normalize_recurrence,
parse_link_titles,
parse_list_items,
parse_tags,
@@ -267,6 +271,57 @@ def test_truthy():
assert not _truthy("")
def test_normalize_recurrence():
for v in ("daily", "weekly", "monthly", "yearly"):
assert normalize_recurrence(v) == v
assert normalize_recurrence("none") is None
assert normalize_recurrence("") is None
assert normalize_recurrence(None) is None
assert normalize_recurrence("hourly") is None
def test_next_occurrence_daily_weekly():
base = datetime(2026, 7, 1, 9, 0, tzinfo=timezone.utc)
after = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc) # same day, later
assert next_occurrence(base, "daily", after) == datetime(2026, 7, 2, 9, 0, tzinfo=timezone.utc)
assert next_occurrence(base, "weekly", after) == datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc)
def test_next_occurrence_skips_missed():
base = datetime(2026, 7, 1, 9, 0, tzinfo=timezone.utc)
after = datetime(2026, 7, 10, 12, 0, tzinfo=timezone.utc) # 9+ days later
# Rolls forward past every missed day to the first fire strictly after `after`.
assert next_occurrence(base, "daily", after) == datetime(2026, 7, 11, 9, 0, tzinfo=timezone.utc)
def test_next_occurrence_monthly_clamps_month_end():
base = datetime(2026, 1, 31, 8, 0, tzinfo=timezone.utc)
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
# Jan 31 + 1 month → Feb 28 (clamped to the shorter month).
assert next_occurrence(base, "monthly", after) == datetime(2026, 2, 28, 8, 0, tzinfo=timezone.utc)
def test_next_occurrence_yearly_and_none():
base = datetime(2026, 3, 15, 7, 0, tzinfo=timezone.utc)
after = datetime(2026, 3, 16, tzinfo=timezone.utc)
assert next_occurrence(base, "yearly", after) == datetime(2027, 3, 15, 7, 0, tzinfo=timezone.utc)
assert next_occurrence(base, "none", after) is None
async def test_complete_reminder_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/reminder/complete")
assert resp.status_code == 401
async def test_snooze_reminder_requires_auth(app):
client = app.test_client()
resp = await client.post(
"/api/notes/00000000-0000-0000-0000-000000000000/reminder/snooze", json={"minutes": 10}
)
assert resp.status_code == 401
def test_usec_to_dt():
# Google Keep timestamps are microseconds since the epoch (UTC).
d = _usec_to_dt(1600000000000000)