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
+13
View File
@@ -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() {
>
<Icon name="bell" /> Reminders
</RouterLink>
<RouterLink
to="/timeline"
class="nav-link"
:class="route.name === 'timeline' ? 'nav-link-active' : ''"
>
<Icon name="calendar" /> Timeline
</RouterLink>
</nav>
</aside>
@@ -43,6 +43,7 @@ const commands = computed<Row[]>(() => {
{ 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") },
];
+1
View File
@@ -19,6 +19,7 @@ const paths: Record<string, string> = {
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
};
</script>
+1
View File
@@ -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") },
],
},
{
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { api } from "../api/client";
import { useNotesStore, type Note } from "../stores/notes";
import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue";
// The "find by WHEN" recall lens: your notes grouped by when you captured them,
// newest first, with an optional date range. A distinct recall axis from search /
// labels — memory is often temporal even when the content is fuzzy.
const notes = useNotesStore();
const items = ref<Note[]>([]);
const loading = ref(true);
const error = ref("");
const editing = ref<Note | null>(null);
// Optional local date-range filter (YYYY-MM-DD from <input type="date">).
const fromDate = ref("");
const toDate = ref("");
const hasRange = computed(() => !!fromDate.value || !!toDate.value);
// Parse an <input type="date"> value ("YYYY-MM-DD") as a LOCAL calendar date.
function localDate(value: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
function buildQuery(): string {
const params = new URLSearchParams({ filter: "active", sort: "created" });
const from = localDate(fromDate.value);
if (from) params.set("created_after", from.toISOString());
const to = localDate(toDate.value);
if (to) {
// Half-open upper bound: start of the day AFTER `to`, so the whole `to` day is included.
const end = new Date(to.getFullYear(), to.getMonth(), to.getDate() + 1);
params.set("created_before", end.toISOString());
}
return params.toString();
}
async function load() {
loading.value = true;
error.value = "";
try {
const res = await api.get<{ notes: Note[] }>(`/api/notes?${buildQuery()}`);
items.value = res.notes;
} catch (e) {
error.value = (e as { error?: string }).error ?? "Couldn't load the timeline.";
items.value = [];
} finally {
loading.value = false;
}
}
function clearRange() {
fromDate.value = "";
toDate.value = "";
}
// Reload when the range changes (watch doesn't fire on mount; onMounted covers first load).
watch([fromDate, toDate], load);
// --- Group notes (already newest-first from the server) into human date buckets,
// computed in the viewer's LOCAL timezone. Server desc order keeps buckets contiguous. ---
interface Group {
key: string;
label: string;
notes: Note[];
}
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
function bucketLabel(created: string | null, now: Date): string {
if (!created) return "Undated";
const d = new Date(created);
if (Number.isNaN(d.getTime())) return "Undated";
const dayDiff = Math.round((startOfDay(now) - startOfDay(d)) / 86400000);
if (dayDiff <= 0) return "Today";
if (dayDiff === 1) return "Yesterday";
if (dayDiff < 7) return "Earlier this week";
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) return "Earlier this month";
return d.toLocaleString(undefined, { month: "long", year: "numeric" });
}
const groups = computed<Group[]>(() => {
const now = new Date();
const out: Group[] = [];
let current: Group | null = null;
for (const n of items.value) {
const label = bucketLabel(n.created_at, now);
if (!current || current.label !== label) {
current = { key: label, label, notes: [] };
out.push(current);
}
current.notes.push(n);
}
return out;
});
function openEditor(n: Note) {
editing.value = n;
}
async function closeEditor() {
editing.value = null;
await load(); // reflect any edits made from a card
}
async function onNavigate(id: string) {
const found = items.value.find((n) => n.id === id) ?? notes.items.find((n) => n.id === id);
editing.value = found ?? (await notes.fetchOne(id));
}
onMounted(load);
</script>
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<div class="mb-1 flex flex-wrap items-center justify-between gap-3">
<h1 class="text-lg font-semibold">Timeline</h1>
<div class="flex flex-wrap items-center gap-2 text-sm">
<label class="flex items-center gap-1.5 text-neutral-500 dark:text-neutral-400">
<span>From</span>
<input
v-model="fromDate"
type="date"
:max="toDate || undefined"
aria-label="From date"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm text-neutral-800 outline-none [color-scheme:light] focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:[color-scheme:dark]"
/>
</label>
<label class="flex items-center gap-1.5 text-neutral-500 dark:text-neutral-400">
<span>To</span>
<input
v-model="toDate"
type="date"
:min="fromDate || undefined"
aria-label="To date"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm text-neutral-800 outline-none [color-scheme:light] focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:[color-scheme:dark]"
/>
</label>
<button
v-if="hasRange"
type="button"
class="rounded-md border border-neutral-300 px-2.5 py-1 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="clearRange"
>
Clear
</button>
</div>
</div>
<p class="mb-5 text-sm text-neutral-500 dark:text-neutral-400">
Your notes by when you captured them{{ hasRange ? " — filtered to the chosen dates" : "" }}.
</p>
<div v-if="loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>
<div v-else-if="error" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Couldn't load the timeline</h2>
<p class="mt-1 text-sm text-neutral-400">{{ error }}</p>
<button
type="button"
class="mt-3 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="load"
>
Retry
</button>
</div>
<div v-else-if="items.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">
{{ hasRange ? "No notes in this range" : "No notes yet" }}
</h2>
<p class="mt-1 text-sm text-neutral-400">
{{
hasRange
? "Try widening the date range, or clear it to see everything."
: "Capture a thought on the board and it'll appear here, dated."
}}
</p>
</div>
<div v-else class="flex flex-col gap-8">
<section v-for="group in groups" :key="group.key">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.label }}</h2>
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
<NoteCard v-for="n in group.notes" :key="n.id" :note="n" @open="openEditor" />
</div>
</section>
</div>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
</template>
</div>
</template>
+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)})
+15
View File
@@ -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")