From 5995b6c725b34caab4d6f5b50735948e5e15ae31 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 10 Mar 2026 15:53:57 -0700 Subject: [PATCH] feat(notes): wire backend-backed note workspace slice --- mobile/src/api/notes.ts | 43 +++++++++++++++++ mobile/src/app/(tabs)/capture.tsx | 12 +++-- mobile/src/store/notes-store.ts | 27 +++++------ web/src/app/(app)/dashboard/page.tsx | 59 +++++++++++++++++++++-- web/src/app/(app)/reviews/page.tsx | 21 ++++++++- web/src/app/(app)/search/page.tsx | 36 ++++++++++---- web/src/app/(app)/workspaces/page.tsx | 32 +++++++++---- web/src/lib/notes-client.ts | 67 ++++++++++++++++++++++++++- 8 files changed, 251 insertions(+), 46 deletions(-) diff --git a/mobile/src/api/notes.ts b/mobile/src/api/notes.ts index 58b7c76..61d77c7 100644 --- a/mobile/src/api/notes.ts +++ b/mobile/src/api/notes.ts @@ -24,6 +24,23 @@ type NoteListResponse = { items: NoteDoc[]; }; +type CreateNoteInput = { + id: string; + workspaceId: string; + title: string; + body: string; + tags?: string[]; + links?: string[]; +}; + +function generateNoteId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + return `note-${Date.now()}`; +} + function mapWorkspaceNames(workspaces: MobileWorkspace[]) { return new Map(workspaces.map((workspace) => [workspace.id, workspace.name])); } @@ -59,6 +76,32 @@ export async function getNote(id: string, workspaceId: string): Promise { + const trimmedTitle = title.trim() || 'Untitled draft'; + const payload: CreateNoteInput = { + id: generateNoteId(), + workspaceId, + title: trimmedTitle, + body, + tags: [], + links: [], + }; + + const [created, workspaces] = await Promise.all([ + getApiClient().fetch('/notes', { + method: 'POST', + body: JSON.stringify(payload), + }), + listWorkspaces(), + ]); + + return toMobileNote(created, mapWorkspaceNames(workspaces)); +} + export async function updateNote( id: string, workspaceId: string, diff --git a/mobile/src/app/(tabs)/capture.tsx b/mobile/src/app/(tabs)/capture.tsx index cc0d49d..9336146 100644 --- a/mobile/src/app/(tabs)/capture.tsx +++ b/mobile/src/app/(tabs)/capture.tsx @@ -45,9 +45,13 @@ export default function CaptureScreen() { textAlignVertical="top" /> { - saveDraft(title, body); - setSaved(true); + onPress={async () => { + const didSave = await saveDraft(activeWorkspaceId, title, body); + setSaved(didSave); + if (!didSave) { + return; + } + setTitle(''); setBody(''); }} @@ -55,7 +59,7 @@ export default function CaptureScreen() { > Save draft - {saved ? Draft saved locally in this scaffold step. : null} + {saved ? Draft saved to the product backend. : null} Offline queue readiness Queue capacity: {OFFLINE_QUEUE_MAX_SIZE} items diff --git a/mobile/src/store/notes-store.ts b/mobile/src/store/notes-store.ts index 24a4931..96acfbb 100644 --- a/mobile/src/store/notes-store.ts +++ b/mobile/src/store/notes-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { getNote, listNotes, updateNote as persistNote, type MobileNote } from '../api/notes'; +import { createNote as persistNewNote, getNote, listNotes, updateNote as persistNote, type MobileNote } from '../api/notes'; export type NotesState = { notes: MobileNote[]; @@ -7,7 +7,7 @@ export type NotesState = { isLoading: boolean; hydrate: () => Promise; openNote: (id: string) => Promise; - saveDraft: (title: string, body: string) => void; + saveDraft: (workspaceId: string | null, title: string, body: string) => Promise; updateNote: (id: string, title: string, body: string) => Promise; }; @@ -31,18 +31,19 @@ export const useNotesStore = create((set, get) => ({ const note = await getNote(id, current.workspaceId); set({ selectedNote: note, isLoading: false }); }, - saveDraft(title: string, body: string) { - const draft: MobileNote = { - id: `draft-${Date.now()}`, - workspaceId: 'drafts', - title: title.trim() || 'Untitled draft', - body, - workspaceName: 'Drafts', - status: 'draft', - updatedAt: new Date().toISOString(), - }; + async saveDraft(workspaceId: string | null, title: string, body: string) { + if (!workspaceId) { + return false; + } - set({ notes: [draft, ...get().notes], selectedNote: draft }); + set({ isLoading: true }); + const created = await persistNewNote(workspaceId, title, body); + set({ + notes: [created, ...get().notes], + selectedNote: created, + isLoading: false, + }); + return true; }, async updateNote(id: string, title: string, body: string) { const nextTitle = title.trim() || 'Untitled draft'; diff --git a/web/src/app/(app)/dashboard/page.tsx b/web/src/app/(app)/dashboard/page.tsx index 8cbe352..56cfe91 100644 --- a/web/src/app/(app)/dashboard/page.tsx +++ b/web/src/app/(app)/dashboard/page.tsx @@ -3,29 +3,78 @@ import { useEffect, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; -import { mockOperatorWorkflows, mockSavedViews } from "@/lib/mock-data"; +import { listApprovalQueue } from "@/lib/review-client"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; export default function DashboardPage() { const [notes, setNotes] = useState([]); const [workspaces, setWorkspaces] = useState([]); + const [pendingReviewCount, setPendingReviewCount] = useState(0); const [error, setError] = useState(null); useEffect(() => { void (async () => { try { - const [nextNotes, nextWorkspaces] = await Promise.all([ + const [nextNotes, nextWorkspaces, nextApprovalQueue] = await Promise.all([ listNoteSummaries(), listWorkspaceSummaries(), + listApprovalQueue(), ]); setNotes(nextNotes); setWorkspaces(nextWorkspaces); + setPendingReviewCount(nextApprovalQueue.length); } catch (err) { setError(err instanceof Error ? err.message : "Unable to load dashboard data"); } })(); }, []); + const savedViews = [ + { + id: "workspace-all", + name: "All workspaces", + scope: "workspace", + description: "Current workspace inventory derived from backend-backed workspace data.", + query: "visibility:any sort:updated", + resultCount: workspaces.length, + }, + { + id: "draft-notes", + name: "Draft notes", + scope: "search", + description: "Draft notes currently tracked across the active knowledge base.", + query: "status:draft", + resultCount: notes.filter((note) => note.status === "draft").length, + }, + { + id: "pending-review", + name: "Pending review", + scope: "review", + description: "Current agent-mediated items awaiting review.", + query: "state:draft|proposed", + resultCount: pendingReviewCount, + }, + ]; + + const operatorWorkflows = [ + { + id: "workflow-approvals", + name: "Approval triage", + owner: "Operator", + queueCount: pendingReviewCount, + sla: "< 4h", + status: pendingReviewCount > 3 ? "at_risk" : "healthy", + }, + { + id: "workflow-workspaces", + name: "Workspace coverage", + owner: "Knowledge Ops", + queueCount: workspaces.length, + sla: "< 1d", + status: workspaces.length > 5 ? "at_risk" : "healthy", + }, + ] as const; + const recentNotes = notes.slice(0, 3); return ( @@ -45,7 +94,7 @@ export default function DashboardPage() {
Pending review surfaces
-
2
+
{pendingReviewCount}
@@ -53,7 +102,7 @@ export default function DashboardPage() {
Saved views
- {mockSavedViews.map((view) => ( + {savedViews.map((view) => (
{view.name} @@ -72,7 +121,7 @@ export default function DashboardPage() {
Operator workflows
- {mockOperatorWorkflows.map((workflow) => ( + {operatorWorkflows.map((workflow) => (
{workflow.name} diff --git a/web/src/app/(app)/reviews/page.tsx b/web/src/app/(app)/reviews/page.tsx index 338bf1b..a474574 100644 --- a/web/src/app/(app)/reviews/page.tsx +++ b/web/src/app/(app)/reviews/page.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { AgentTimeline } from "@/components/AgentTimeline"; import { ProposalReviewCard } from "@/components/ProposalReviewCard"; -import { mockOperatorWorkflows } from "@/lib/mock-data"; import { listAgentTimeline, listApprovalQueue } from "@/lib/review-client"; import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types"; @@ -29,6 +28,24 @@ export default function ReviewsPage() { }, []); const featuredProposal = useMemo(() => approvalQueue[0] ?? null, [approvalQueue]); + const operatorWorkflows = [ + { + id: "workflow-approvals", + name: "Approval triage", + owner: "Operator", + queueCount: approvalQueue.length, + sla: "< 4h", + status: approvalQueue.length > 3 ? "at_risk" : "healthy", + }, + { + id: "workflow-agent-activity", + name: "Agent activity review", + owner: "Knowledge Ops", + queueCount: timeline.length, + sla: "< 1d", + status: timeline.length > 6 ? "at_risk" : "healthy", + }, + ] as const; return (
Operator workflows
- {mockOperatorWorkflows.map((workflow) => ( + {operatorWorkflows.map((workflow) => (
{workflow.name} Owner: {workflow.owner} diff --git a/web/src/app/(app)/search/page.tsx b/web/src/app/(app)/search/page.tsx index cc02507..5358e7a 100644 --- a/web/src/app/(app)/search/page.tsx +++ b/web/src/app/(app)/search/page.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { listNoteSummaries } from "@/lib/notes-client"; -import { mockSavedViews } from "@/lib/mock-data"; import type { NoteSummary } from "@/lib/types"; export default function SearchPage() { @@ -35,6 +34,25 @@ export default function SearchPage() { ); }, [notes, query]); + const savedViews = [ + { + id: "search-launch-readiness", + name: "Launch readiness", + query: "tag:launch tag:mvp status:active", + resultCount: notes.filter( + (note) => + note.status === "active" && + note.tags.some((tag) => tag === "launch" || tag === "mvp") + ).length, + }, + { + id: "search-drafts", + name: "Draft notes", + query: "status:draft", + resultCount: notes.filter((note) => note.status === "draft").length, + }, + ]; + return (
Saved searches
- {mockSavedViews - .filter((view) => view.scope === "search") - .map((view) => ( -
- {view.name} - {view.query} - {view.resultCount} results -
- ))} + {savedViews.map((view) => ( +
+ {view.name} + {view.query} + {view.resultCount} results +
+ ))}
diff --git a/web/src/app/(app)/workspaces/page.tsx b/web/src/app/(app)/workspaces/page.tsx index deb9630..e82b6e6 100644 --- a/web/src/app/(app)/workspaces/page.tsx +++ b/web/src/app/(app)/workspaces/page.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; -import { mockSavedViews } from "@/lib/mock-data"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; export default function WorkspacesPage() { @@ -38,6 +37,21 @@ export default function WorkspacesPage() { [notes, workspaces], ); + const savedViews = [ + { + id: "workspace-all", + name: "All workspaces", + description: "Current backend-backed workspace inventory.", + resultCount: workspaces.length, + }, + { + id: "workspace-shared", + name: "Shared workspaces", + description: "Workspaces with more than one member.", + resultCount: workspaces.filter((workspace) => workspace.visibility === "shared").length, + }, + ]; + return (
Saved views
- {mockSavedViews - .filter((view) => view.scope === "workspace") - .map((view) => ( -
- {view.name} - {view.description} - {view.resultCount} results -
- ))} + {savedViews.map((view) => ( +
+ {view.name} + {view.description} + {view.resultCount} results +
+ ))}
diff --git a/web/src/lib/notes-client.ts b/web/src/lib/notes-client.ts index c98ec38..799489f 100644 --- a/web/src/lib/notes-client.ts +++ b/web/src/lib/notes-client.ts @@ -1,7 +1,7 @@ import { createApiClient } from "@bytelyst/api-client"; import { extractSuggestedTasks } from "@/lib/extraction-client"; import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config"; -import type { AgentTimelineItem, ArtifactSummary, NoteDetail, NoteSummary, NoteTask, WorkspaceSummary } from "@/lib/types"; +import type { AgentTimelineItem, ArtifactSummary, LinkedNote, NoteDetail, NoteSummary, NoteTask, WorkspaceSummary } from "@/lib/types"; type NoteDoc = { id: string; @@ -78,6 +78,18 @@ type NoteAgentActionListResponse = { items: NoteAgentActionDoc[]; }; +type NoteRelationshipDoc = { + id: string; + workspaceId: string; + fromNoteId: string; + toNoteId: string; + relationshipType: string; +}; + +type NoteRelationshipListResponse = { + items: NoteRelationshipDoc[]; +}; + function getAccessToken(): string | null { if (typeof window === "undefined") { return null; @@ -100,6 +112,10 @@ function buildWorkspaceMap(workspaces: WorkspaceDoc[]) { return new Map(workspaces.map((workspace) => [workspace.id, workspace])); } +function buildNoteMap(notes: NoteDoc[]) { + return new Map(notes.map((note) => [note.id, note])); +} + function toNoteSummary(note: NoteDoc): NoteSummary { return { id: note.id, @@ -176,6 +192,38 @@ function toReviewState( return "none"; } +function toLinkedNotes( + noteId: string, + relationships: NoteRelationshipDoc[], + noteMap: Map, +): LinkedNote[] { + return relationships.flatMap((relationship) => { + const relatedNoteId = + relationship.fromNoteId === noteId + ? relationship.toNoteId + : relationship.toNoteId === noteId + ? relationship.fromNoteId + : null; + + if (!relatedNoteId) { + return []; + } + + const relatedNote = noteMap.get(relatedNoteId); + if (!relatedNote) { + return []; + } + + return [ + { + id: relatedNote.id, + title: relatedNote.title, + relationship: relationship.relationshipType, + }, + ]; + }); +} + export async function listWorkspaceSummaries(): Promise { const api = createNotesApiClient(); const [workspaceResponse, noteResponse] = await Promise.all([ @@ -211,6 +259,7 @@ export async function getNoteDetail(noteId: string): Promise } const workspaceMap = buildWorkspaceMap(workspaceResponse.items); + const noteMap = buildNoteMap(noteResponse.items); const workspace = workspaceMap.get(note.workspaceId); const [taskResponse, artifactResponse, actionResponse] = await Promise.all([ api.fetch( @@ -223,6 +272,19 @@ export async function getNoteDetail(noteId: string): Promise `/note-agent-actions?workspaceId=${encodeURIComponent(note.workspaceId)}¬eId=${encodeURIComponent(note.id)}` ), ]); + let relationshipResponse: NoteRelationshipListResponse = { items: [] }; + + try { + const fetchedRelationships = await api.fetch( + `/note-relationships?workspaceId=${encodeURIComponent(note.workspaceId)}¬eId=${encodeURIComponent(note.id)}` + ); + relationshipResponse = + fetchedRelationships && Array.isArray(fetchedRelationships.items) + ? fetchedRelationships + : { items: [] }; + } catch { + relationshipResponse = { items: [] }; + } const extractedTasks = await extractSuggestedTasks(note.body).catch(() => []); const existingTaskTitles = new Set(taskResponse.items.map((task) => task.title.trim().toLowerCase())); @@ -234,6 +296,7 @@ export async function getNoteDetail(noteId: string): Promise const timeline = actionResponse.items .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .map(toTimelineItem); + const linkedNotes = toLinkedNotes(note.id, relationshipResponse.items, noteMap); return { ...toNoteSummary(note), @@ -245,7 +308,7 @@ export async function getNoteDetail(noteId: string): Promise taskCount: tasks.length, artifactCount: artifacts.length, }, - linkedNotes: [], + linkedNotes, tasks, artifacts, timeline,