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:
@@ -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") },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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") },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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)})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user