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 Board", keys: ["g", "b"] },
|
||||||
{ label: "Go to Graph", keys: ["g", "g"] },
|
{ label: "Go to Graph", keys: ["g", "g"] },
|
||||||
{ label: "Go to Reminders", keys: ["g", "r"] },
|
{ label: "Go to Reminders", keys: ["g", "r"] },
|
||||||
|
{ label: "Go to Timeline", keys: ["g", "t"] },
|
||||||
{ label: "Move card focus", keys: ["j", "k"] },
|
{ label: "Move card focus", keys: ["j", "k"] },
|
||||||
{ label: "Open focused card", keys: ["Enter"] },
|
{ label: "Open focused card", keys: ["Enter"] },
|
||||||
{ label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
|
{ label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
|
||||||
@@ -102,6 +103,11 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
void router.push("/reminders");
|
void router.push("/reminders");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e.key === "t") {
|
||||||
|
e.preventDefault();
|
||||||
|
void router.push("/timeline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (e.key === "/") {
|
if (e.key === "/") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -286,6 +292,13 @@ async function signOut() {
|
|||||||
>
|
>
|
||||||
<Icon name="bell" /> Reminders
|
<Icon name="bell" /> Reminders
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/timeline"
|
||||||
|
class="nav-link"
|
||||||
|
:class="route.name === 'timeline' ? 'nav-link-active' : ''"
|
||||||
|
>
|
||||||
|
<Icon name="calendar" /> Timeline
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const commands = computed<Row[]>(() => {
|
|||||||
{ id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") },
|
{ 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:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") },
|
||||||
{ id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
|
{ 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:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") },
|
||||||
{ id: "cmd:trash", label: "Go to Trash", hint: "Navigate", run: () => go("/trash") },
|
{ 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"/>',
|
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"/>',
|
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"/>',
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const router = createRouter({
|
|||||||
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
||||||
{ path: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
|
{ path: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
|
||||||
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.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)
|
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("")
|
@bp.get("")
|
||||||
@login_required
|
@login_required
|
||||||
async def list_notes():
|
async def list_notes():
|
||||||
@@ -278,6 +284,14 @@ async def list_notes():
|
|||||||
if filter_name not in VALID_FILTERS:
|
if filter_name not in VALID_FILTERS:
|
||||||
return jsonify({"error": "invalid filter"}), 400
|
return jsonify({"error": "invalid filter"}), 400
|
||||||
label_param = request.args.get("label")
|
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:
|
async with session_scope() as db:
|
||||||
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||||
stmt = apply_filter(stmt, filter_name)
|
stmt = apply_filter(stmt, filter_name)
|
||||||
@@ -287,7 +301,20 @@ async def list_notes():
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({"error": "invalid label"}), 400
|
return jsonify({"error": "invalid label"}), 400
|
||||||
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
|
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()
|
notes = (await db.scalars(stmt)).all()
|
||||||
return jsonify({"notes": await _serialize_notes(db, notes)})
|
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.models.note import NOTE_COLORS, Note
|
||||||
from thoughtsync.notes import (
|
from thoughtsync.notes import (
|
||||||
_escape_like,
|
_escape_like,
|
||||||
|
_parse_iso_dt,
|
||||||
derive_display_title,
|
derive_display_title,
|
||||||
is_empty_note,
|
is_empty_note,
|
||||||
normalize_color,
|
normalize_color,
|
||||||
@@ -157,6 +158,20 @@ def test_escape_like():
|
|||||||
assert _escape_like("plain") == "plain"
|
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):
|
async def test_titles_requires_auth(app):
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.get("/api/notes/titles")
|
resp = await client.get("/api/notes/titles")
|
||||||
|
|||||||
Reference in New Issue
Block a user