From 95b0e30fc7ded3b1f0db7ed3fb897ce530a31bbe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Jul 2026 12:26:35 -0400 Subject: [PATCH] M6: browse notes by creation date (Timeline lens) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- frontend/src/components/AppShell.vue | 13 ++ frontend/src/components/CommandPalette.vue | 1 + frontend/src/components/Icon.vue | 1 + frontend/src/router/index.ts | 1 + frontend/src/views/TimelineView.vue | 197 +++++++++++++++++++++ src/thoughtsync/notes.py | 29 ++- tests/test_notes.py | 15 ++ 7 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 frontend/src/views/TimelineView.vue diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 950f77a..b7f8741 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -32,6 +32,7 @@ const shortcuts = [ { label: "Go to Board", keys: ["g", "b"] }, { label: "Go to Graph", keys: ["g", "g"] }, { label: "Go to Reminders", keys: ["g", "r"] }, + { label: "Go to Timeline", keys: ["g", "t"] }, { label: "Move card focus", keys: ["j", "k"] }, { label: "Open focused card", keys: ["Enter"] }, { label: "Pin / archive / trash card", keys: ["#", "e", "x"] }, @@ -102,6 +103,11 @@ function onKeydown(e: KeyboardEvent) { void router.push("/reminders"); return; } + if (e.key === "t") { + e.preventDefault(); + void router.push("/timeline"); + return; + } } if (e.key === "/") { e.preventDefault(); @@ -286,6 +292,13 @@ async function signOut() { > Reminders + + Timeline + diff --git a/frontend/src/components/CommandPalette.vue b/frontend/src/components/CommandPalette.vue index 9c4f2f6..a9408b8 100644 --- a/frontend/src/components/CommandPalette.vue +++ b/frontend/src/components/CommandPalette.vue @@ -43,6 +43,7 @@ const commands = computed(() => { { id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") }, { id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") }, { id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") }, + { id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") }, { id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") }, { id: "cmd:trash", label: "Go to Trash", hint: "Navigate", run: () => go("/trash") }, ]; diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 1271f78..c2d6065 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -19,6 +19,7 @@ const paths: Record = { graph: '', bell: '', grip: '', + calendar: '', }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index fd0108d..5f43f15 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -18,6 +18,7 @@ const router = createRouter({ { path: "search", name: "search", component: () => import("../views/SearchView.vue") }, { path: "graph", name: "graph", component: () => import("../views/GraphView.vue") }, { path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") }, + { path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") }, ], }, { diff --git a/frontend/src/views/TimelineView.vue b/frontend/src/views/TimelineView.vue new file mode 100644 index 0000000..55b1e7a --- /dev/null +++ b/frontend/src/views/TimelineView.vue @@ -0,0 +1,197 @@ + + + diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index e5c2437..8febc1e 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -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)}) diff --git a/tests/test_notes.py b/tests/test_notes.py index e6d3e00..c8326c3 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -4,6 +4,7 @@ from thoughtsync.app import create_app from thoughtsync.models.note import NOTE_COLORS, Note from thoughtsync.notes import ( _escape_like, + _parse_iso_dt, derive_display_title, is_empty_note, normalize_color, @@ -157,6 +158,20 @@ def test_escape_like(): assert _escape_like("plain") == "plain" +def test_parse_iso_dt(): + # A full ISO instant round-trips (used to validate the Timeline date range). + d = _parse_iso_dt("2026-07-19T12:30:00+00:00") + assert (d.year, d.month, d.day, d.hour, d.minute) == (2026, 7, 19, 12, 30) + assert d.tzinfo is not None + # a trailing Z is accepted as UTC + assert _parse_iso_dt("2026-07-19T00:00:00Z").tzinfo is not None + # a plain calendar date parses to midnight + assert _parse_iso_dt("2026-07-19").hour == 0 + # garbage raises (the endpoint turns this into a 400) + with pytest.raises(ValueError): + _parse_iso_dt("not-a-date") + + async def test_titles_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/titles")