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()) archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
# Soft delete: non-null => in Trash. Restore sets it back to null. # Soft delete: non-null => in Trash. Restore sets it back to null.
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) 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) 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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
@@ -80,6 +83,7 @@ class Note(Base):
"archived": self.archived, "archived": self.archived,
"trashed": self.deleted_at is not None, "trashed": self.deleted_at is not None,
"remind_at": self.remind_at.isoformat() if self.remind_at else 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, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_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 from __future__ import annotations
import calendar
import hashlib import hashlib
import io import io
import json import json
@@ -8,7 +9,7 @@ import posixpath
import re import re
import uuid import uuid
import zipfile import zipfile
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from quart import Blueprint, Response, g, jsonify, request, send_file from quart import Blueprint, Response, g, jsonify, request, send_file
from sqlalchemy import case, delete, func, literal_column, select 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")) 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: def _truthy(raw: str | None) -> bool:
return raw in ("true", "1", "yes", "on") return raw in ("true", "1", "yes", "on")
@@ -439,6 +483,43 @@ async def list_reminders():
return jsonify({"notes": await _serialize_notes(db, notes)}) 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: def _slugify(text: str) -> str:
"""A filesystem-safe slug from a note's display name (for the .md filename).""" """A filesystem-safe slug from a note's display name (for the .md filename)."""
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower()) s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
@@ -523,6 +604,7 @@ async def export_notes():
"pinned": n.pinned, "pinned": n.pinned,
"archived": n.archived, "archived": n.archived,
"remind_at": n.remind_at.isoformat() if n.remind_at else None, "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, "created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None, "updated_at": n.updated_at.isoformat() if n.updated_at else None,
"labels": [lb["name"] for lb in labels], "labels": [lb["name"] for lb in labels],
@@ -604,6 +686,7 @@ def _native_spec(n: dict) -> dict:
"archived": bool(n.get("archived")), "archived": bool(n.get("archived")),
"trashed": False, # export only includes live notes "trashed": False, # export only includes live notes
"remind_at": _iso_to_dt(n.get("remind_at")), "remind_at": _iso_to_dt(n.get("remind_at")),
"recurrence": normalize_recurrence(n.get("recurrence")),
"created_at": _iso_to_dt(n.get("created_at")), "created_at": _iso_to_dt(n.get("created_at")),
"updated_at": _iso_to_dt(n.get("updated_at")), "updated_at": _iso_to_dt(n.get("updated_at")),
"labels": [s for s in (n.get("labels") or []) if isinstance(s, str)], "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"): if spec.get("remind_at"):
note.remind_at = spec["remind_at"] note.remind_at = spec["remind_at"]
if spec.get("recurrence"):
note.recurrence = spec["recurrence"]
if spec.get("trashed"): if spec.get("trashed"):
note.deleted_at = datetime.now(timezone.utc) note.deleted_at = datetime.now(timezone.utc)
# Preserve source timestamps: set before flush so they land in the INSERT # 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"] raw = data["remind_at"]
if raw in (None, ""): if raw in (None, ""):
note.remind_at = None note.remind_at = None
note.recurrence = None # no reminder → recurrence is moot
else: else:
try: try:
note.remind_at = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) note.remind_at = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except ValueError: except ValueError:
return jsonify({"error": "invalid remind_at"}), 400 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 # Recompute the display name (explicit title, else first body line) whenever
# the title or body may have changed. # the title or body may have changed.
if "title" in data or "body" in data: if "title" in data or "body" in data:
+2
View File
@@ -34,6 +34,7 @@ from .notes import (
_serialize_notes, _serialize_notes,
derive_display_title, derive_display_title,
normalize_color, normalize_color,
normalize_recurrence,
) )
bp = Blueprint("sync", __name__, url_prefix="/api/sync") bp = Blueprint("sync", __name__, url_prefix="/api/sync")
@@ -178,6 +179,7 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
else: else:
note.deleted_at = None note.deleted_at = None
note.remind_at = _parse_client_dt(ch.get("remind_at")) note.remind_at = _parse_client_dt(ch.get("remind_at"))
note.recurrence = normalize_recurrence(ch.get("recurrence"))
if isinstance(ch.get("position"), int): if isinstance(ch.get("position"), int):
note.position = ch["position"] note.position = ch["position"]
+55
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
import pytest import pytest
from thoughtsync.app import create_app from thoughtsync.app import create_app
@@ -15,7 +17,9 @@ from thoughtsync.notes import (
_usec_to_dt, _usec_to_dt,
derive_display_title, derive_display_title,
is_empty_note, is_empty_note,
next_occurrence,
normalize_color, normalize_color,
normalize_recurrence,
parse_link_titles, parse_link_titles,
parse_list_items, parse_list_items,
parse_tags, parse_tags,
@@ -267,6 +271,57 @@ def test_truthy():
assert not _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(): def test_usec_to_dt():
# Google Keep timestamps are microseconds since the epoch (UTC). # Google Keep timestamps are microseconds since the epoch (UTC).
d = _usec_to_dt(1600000000000000) d = _usec_to_dt(1600000000000000)