M6: browse notes by creation date (Timeline lens)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 1m2s

A temporal recall path — find a note by WHEN it was captured, not just what it contains (task 1903, first of the M6 recall items).

Backend: list_notes gains an optional created_at range (created_after / created_before, half-open interval) + sort=created; also lays groundwork for the richer-search facets (task 1902). New _parse_iso_dt helper with a DB-free unit test.

Frontend: a Timeline view (sidebar nav + 'g t' + command palette) grouping active notes newest-first into local-time buckets (Today / Yesterday / Earlier this week / this month / Month YYYY), plus an optional From/To date filter. Built as a lens on the same NoteCard masonry, consistent with the existing Reminders/Search views.

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-22 12:26:35 -04:00
co-authored by Claude Opus 4.8
parent c4914fe587
commit 95b0e30fc7
7 changed files with 256 additions and 1 deletions
+28 -1
View File
@@ -271,6 +271,12 @@ async def _rename_inbound_links(db, renamed: Note, old_title: str, new_title: st
await _rewrite_links(db, source)
def _parse_iso_dt(raw: str) -> datetime:
"""Parse an ISO-8601 timestamp (accepting a trailing 'Z' for UTC), raising
ValueError on anything unparseable — used to validate date-range query params."""
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
@bp.get("")
@login_required
async def list_notes():
@@ -278,6 +284,14 @@ async def list_notes():
if filter_name not in VALID_FILTERS:
return jsonify({"error": "invalid filter"}), 400
label_param = request.args.get("label")
# Optional creation-date range — the "browse by when" / Timeline lens. Both bounds
# are ISO-8601 instants forming a HALF-OPEN interval [created_after, created_before),
# so a client can pass local day-boundaries (start-of-day .. start-of-next-day)
# without off-by-one. `sort=created` orders newest-captured first for a chronological
# timeline; the default keeps the board's pinned/position/updated order.
after_param = request.args.get("created_after")
before_param = request.args.get("created_before")
sort = request.args.get("sort")
async with session_scope() as db:
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
stmt = apply_filter(stmt, filter_name)
@@ -287,7 +301,20 @@ async def list_notes():
except (ValueError, TypeError):
return jsonify({"error": "invalid label"}), 400
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc())
if after_param:
try:
stmt = stmt.where(Note.created_at >= _parse_iso_dt(after_param))
except ValueError:
return jsonify({"error": "invalid created_after"}), 400
if before_param:
try:
stmt = stmt.where(Note.created_at < _parse_iso_dt(before_param))
except ValueError:
return jsonify({"error": "invalid created_before"}), 400
if sort == "created":
stmt = stmt.order_by(Note.created_at.desc())
else:
stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc())
notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)})