M6 1908a: recurring reminders + complete/snooze (backend, no web-push)
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:
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user