From 882d4206aa46d0d9c8a10efeec901c2a58d71cc6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Jul 2026 11:53:07 -0400 Subject: [PATCH] M6 1908a: recurring reminders + complete/snooze (backend, no web-push) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0022_note_recurrence.py | 24 +++++++ src/thoughtsync/models/note.py | 6 +- src/thoughtsync/notes.py | 90 +++++++++++++++++++++++- src/thoughtsync/sync.py | 2 + tests/test_notes.py | 55 +++++++++++++++ 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/0022_note_recurrence.py diff --git a/alembic/versions/0022_note_recurrence.py b/alembic/versions/0022_note_recurrence.py new file mode 100644 index 0000000..1027d32 --- /dev/null +++ b/alembic/versions/0022_note_recurrence.py @@ -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") diff --git a/src/thoughtsync/models/note.py b/src/thoughtsync/models/note.py index 3c3417c..fb1bc00 100644 --- a/src/thoughtsync/models/note.py +++ b/src/thoughtsync/models/note.py @@ -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, } diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index d6cc301..fb27399 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -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("//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("//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: diff --git a/src/thoughtsync/sync.py b/src/thoughtsync/sync.py index eede8e2..76c2e59 100644 --- a/src/thoughtsync/sync.py +++ b/src/thoughtsync/sync.py @@ -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"] diff --git a/tests/test_notes.py b/tests/test_notes.py index fe934cf..ea0fbd0 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -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)