M6: browse notes by creation date (Timeline lens)
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:
@@ -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)})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user