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:
@@ -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