From 590d3ff2f6032c3e1c6bbe59bd956f3aeaeb687b Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 23 Jul 2026 21:36:24 -0400
Subject: [PATCH] M9 S3 (frontend): recall views adopt useNoteList +
AsyncState/EmptyState
The Search / Timeline / Reminders views each hand-rolled the same
items+loading+error scaffold, retry button, and editor-host glue.
- useNoteList(fetcher, fallbackError) (new): the load-a-note-list scaffold
(items/loading/error + a load() that never leaves a half-state). Views
supply just the fetcher; the refs drive .
- SearchView / TimelineView / RemindersView: adopt useNoteList + useNoteEditor
+ /; drop the local list/loading/error refs, the
duplicated retry blocks, and the notes.items-shadowing navigate glue.
- GraphView: editor host now via useNoteEditor (openNode/closeEditor/onNavigate
collapse to navigate); loading/error via . Its two empty states
keep inline markup/buttons, so they stay custom (not forced into EmptyState).
- reminders store: fetchReminders() is the single owner of /api/notes/reminders;
both the background poll (check) and RemindersView read through it.
Frontend-only; CI vue-tsc is the type gate (no local typecheck, rule 10).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
---
frontend/src/composables/useNoteList.ts | 38 +++++++
frontend/src/stores/reminders.ts | 10 +-
frontend/src/views/GraphView.vue | 46 +++-----
frontend/src/views/RemindersView.vue | 143 ++++++++++--------------
frontend/src/views/SearchView.vue | 87 ++++----------
frontend/src/views/TimelineView.vue | 100 ++++++-----------
6 files changed, 173 insertions(+), 251 deletions(-)
create mode 100644 frontend/src/composables/useNoteList.ts
diff --git a/frontend/src/composables/useNoteList.ts b/frontend/src/composables/useNoteList.ts
new file mode 100644
index 0000000..5093eed
--- /dev/null
+++ b/frontend/src/composables/useNoteList.ts
@@ -0,0 +1,38 @@
+import { ref, type Ref } from "vue";
+import type { Note } from "../stores/notes";
+
+// The load-a-list-of-notes scaffold every recall view (Search, Timeline, Reminders)
+// hand-rolled: an items ref + loading/error flags + a load() that fills them and never
+// leaves a half-state. The view supplies just the fetcher; wire the returned refs into
+// and pass `items` to /cards.
+//
+// `fetcher` returns the notes to show (or [] — e.g. an empty search box). A thrown
+// ApiError's `.error` becomes the message; otherwise `fallbackError` is shown.
+export function useNoteList(
+ fetcher: () => Promise,
+ fallbackError = "Couldn't load your notes.",
+): {
+ items: Ref;
+ loading: Ref;
+ error: Ref;
+ load: () => Promise;
+} {
+ const items = ref([]);
+ const loading = ref(false);
+ const error = ref("");
+
+ async function load(): Promise {
+ loading.value = true;
+ error.value = "";
+ try {
+ items.value = await fetcher();
+ } catch (e) {
+ error.value = (e as { error?: string }).error ?? fallbackError;
+ items.value = [];
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ return { items, loading, error, load };
+}
diff --git a/frontend/src/stores/reminders.ts b/frontend/src/stores/reminders.ts
index 95f3e5b..087f59b 100644
--- a/frontend/src/stores/reminders.ts
+++ b/frontend/src/stores/reminders.ts
@@ -20,11 +20,17 @@ export const useReminderStore = defineStore("reminders", () => {
let timer: ReturnType | undefined;
let primed = false;
+ // Single owner of the reminders endpoint — the RemindersView reads its list through
+ // this too, so the URL + response shape live in one place.
+ async function fetchReminders(): Promise {
+ return (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes;
+ }
+
async function check(): Promise {
const ui = useUiStore();
let notes: Note[];
try {
- notes = (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes;
+ notes = await fetchReminders();
} catch {
return;
}
@@ -66,5 +72,5 @@ export const useReminderStore = defineStore("reminders", () => {
osEnabled.value = (await Notification.requestPermission()) === "granted";
}
- return { osSupported, osEnabled, start, stop, enableOs };
+ return { osSupported, osEnabled, start, stop, enableOs, fetchReminders };
});
diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue
index cc58899..00821cd 100644
--- a/frontend/src/views/GraphView.vue
+++ b/frontend/src/views/GraphView.vue
@@ -2,8 +2,9 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api/client";
-import { useNotesStore, type Note } from "../stores/notes";
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
+import { useNoteEditor } from "../composables/useNoteEditor";
+import AsyncState from "../components/AsyncState.vue";
import NoteEditor from "../components/NoteEditor.vue";
type NodeKind = "note" | "label";
@@ -29,12 +30,12 @@ const WIDTH = 1000;
const HEIGHT = 700;
const router = useRouter();
-const notes = useNotesStore();
const allNodes = ref([]);
const edges = ref([]);
const loading = ref(true);
const error = ref("");
-const editing = ref(null);
+// Editor host: resolve a node id against the store, else fetch (shared controller).
+const { editing, close: closeEditor, navigate } = useNoteEditor();
// Unlinked notes float in the space by default — the graph is a gentle overview,
// not a links-only surface. Label hubs are on by default so tags cluster notes.
const showAll = ref(true);
@@ -263,7 +264,7 @@ function clickNode(n: GNode) {
void router.push(`/label/${n.labelId}`);
return;
}
- void openNode(n);
+ void navigate(n.id);
}
function onWheel(e: WheelEvent) {
@@ -293,18 +294,6 @@ function toggleLabels() {
reheat();
}
-async function openNode(n: GNode) {
- const found = notes.items.find((x) => x.id === n.id);
- editing.value = found ?? (await notes.fetchOne(n.id));
-}
-function closeEditor() {
- editing.value = null;
-}
-async function onNavigate(id: string) {
- const found = notes.items.find((x) => x.id === id);
- editing.value = found ?? (await notes.fetchOne(id));
-}
-
onMounted(loadGraph);
onBeforeUnmount(() => {
cancelAnimationFrame(raf);
@@ -336,21 +325,13 @@ onBeforeUnmount(() => {
-
Loading graph…
-
-
-
Couldn't load the graph
-
{{ error }}
-
-
-
-
+
+
No notes yet
Create notes and link them with
@@ -430,9 +411,10 @@ onBeforeUnmount(() => {
+
-
+
diff --git a/frontend/src/views/RemindersView.vue b/frontend/src/views/RemindersView.vue
index 04e847b..42f539c 100644
--- a/frontend/src/views/RemindersView.vue
+++ b/frontend/src/views/RemindersView.vue
@@ -1,42 +1,23 @@
@@ -64,28 +37,12 @@ async function onNavigate(id: string) {
Type in the search box to find your notes.
-
Searching…
-
-
-
Couldn't search
-
{{ error }}
-
-
-
-
-
No matches
-
Nothing found for "{{ query }}".
-
-
-
-
-
+
+
+
+
+
+
diff --git a/frontend/src/views/TimelineView.vue b/frontend/src/views/TimelineView.vue
index 55b1e7a..6cb2c9a 100644
--- a/frontend/src/views/TimelineView.vue
+++ b/frontend/src/views/TimelineView.vue
@@ -1,18 +1,17 @@
@@ -154,41 +143,22 @@ onMounted(load);
Your notes by when you captured them{{ hasRange ? " — filtered to the chosen dates" : "" }}.
-
Loading…
-
-
-
Couldn't load the timeline
-
{{ error }}
-
-
-
-
-
- {{ hasRange ? "No notes in this range" : "No notes yet" }}
-
-
- {{
- hasRange
- ? "Try widening the date range, or clear it to see everything."
- : "Capture a thought on the board and it'll appear here, dated."
- }}
-