From e0e8f4e7e866d32ae0642c12b39e10c66c0d50b1 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Thu, 26 Mar 2026 11:06:40 -0500 Subject: [PATCH] routing added --- src/App.tsx | 171 ++++++++++++++++++++++++++++++++++---- src/components/Layout.tsx | 64 ++++++++++---- src/views/HelpView.tsx | 16 +++- 3 files changed, 218 insertions(+), 33 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 881421d..97c0d07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,16 +21,93 @@ const AIHelpView = lazy(() => import('./views/AIHelpView').then(m => ({ default: const NotepadView = lazy(() => import('./views/NotepadView').then(m => ({ default: m.NotepadView }))); const QuickEntryForm = lazy(() => import('./components/QuickEntryForm').then(m => ({ default: m.QuickEntryForm }))); +const DEFAULT_VIEW = "critical"; +const routeToView: Record = { + critical: "critical", + focus: "critical", + urgency: "urgency", + priority: "priority", + matrix: "matrix", + snowball: "snowball", + "dig-in": "dig_in", + "dig_in": "dig_in", + progress: "progress", + settings: "settings", + help: "help", + ask: "help_ai", + "help-ai": "help_ai", + "help_ai": "help_ai", +}; + +const viewToRoute: Record = { + critical: "focus", + urgency: "urgency", + priority: "priority", + matrix: "matrix", + snowball: "snowball", + dig_in: "dig-in", + progress: "progress", + settings: "settings", + help: "help", + help_ai: "ask", +}; + +const normalizeHash = (hash: string) => { + if (hash === "#help" || hash.startsWith("#help/")) { + return `#/${hash.slice(1)}`; + } + return hash; +}; + +const parseHashRoute = (hash: string): { view: string; sectionId?: string } | null => { + const normalizedHash = normalizeHash(hash); + if (!normalizedHash.startsWith("#/")) return null; + + const routePath = normalizedHash.slice(2); + if (!routePath || routePath.startsWith("embed/")) return null; + + const [viewSegment, ...rest] = routePath.split("/"); + if (decodeURIComponent(viewSegment) === "notes") { + return { + view: "notes", + sectionId: rest.length > 0 ? decodeURIComponent(rest.join("/")) : undefined, + }; + } + const view = routeToView[decodeURIComponent(viewSegment)]; + if (!view) return null; + + if (view === "help") { + return { + view, + sectionId: rest.length > 0 ? decodeURIComponent(rest.join("/")) : undefined, + }; + } + + return { view }; +}; + +const getHashForView = (view: string, sectionId?: string) => { + if (view === "notes") { + return sectionId ? `#/notes/${encodeURIComponent(sectionId)}` : "#/notes"; + } + const route = viewToRoute[view] || viewToRoute[DEFAULT_VIEW]; + if (view === "help" && sectionId) { + return `#/${route}/${encodeURIComponent(sectionId)}`; + } + return `#/${route}`; +}; + const App: Component = () => { // Basic routing state - const [currentView, setCurrentView] = createSignal("critical"); - const [lastNonAIView, setLastNonAIView] = createSignal("critical"); + const [currentView, setCurrentView] = createSignal(DEFAULT_VIEW); + const [lastNonAIView, setLastNonAIView] = createSignal(DEFAULT_VIEW); const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid); const [authUserId, setAuthUserId] = createSignal(pb.authStore.model?.id || null); const [location, setLocation] = createSignal(window.location.href); const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches); const [aiHelpTip, setAiHelpTip] = createSignal(null); const [tipGeneratedForUserId, setTipGeneratedForUserId] = createSignal(null); + const [selectedNoteId, setSelectedNoteId] = createSignal(null); createEffect(() => { const view = currentView(); @@ -43,6 +120,21 @@ const App: Component = () => { currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView() ); + const syncNotesRoute = (noteId?: string | null, options?: { replace?: boolean }) => { + syncUrlToView("notes", { sectionId: noteId || undefined, replace: options?.replace }); + }; + + const openNotes = (noteId?: string | null, options?: { replace?: boolean }) => { + setStore('isNotepadMode', true); + setSelectedNoteId(noteId || null); + syncNotesRoute(noteId, options); + }; + + const openTasks = (view?: string, options?: { replace?: boolean }) => { + setStore('isNotepadMode', false); + navigateToView(view || currentView(), options); + }; + createEffect(() => { const userId = authUserId(); const authenticated = isAuthenticated(); @@ -68,21 +160,38 @@ const App: Component = () => { })(); }); + const syncUrlToView = (view: string, options?: { sectionId?: string; replace?: boolean }) => { + if (isEmbed()) return; + + const nextHash = getHashForView(view, options?.sectionId); + if (window.location.hash === nextHash) return; + + if (options?.replace) { + const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`; + window.history.replaceState(window.history.state, "", nextUrl); + window.dispatchEvent(new HashChangeEvent("hashchange")); + return; + } + + window.location.hash = nextHash; + }; + + const navigateToView = (view: string, options?: { sectionId?: string; replace?: boolean }) => { + setCurrentView(view); + syncUrlToView(view, options); + }; + onMount(() => { const mediaQuery = window.matchMedia("(min-width: 768px)"); const openHelpGuide = (sectionId?: string) => { - const nextHash = sectionId ? `#help/${sectionId}` : "#help"; - if (window.location.hash !== nextHash) { - window.location.hash = nextHash; - } setStore('isNotepadMode', false); - setCurrentView("help"); + navigateToView("help", { sectionId }); window.dispatchEvent(new CustomEvent("taskgrid:help-target", { detail: { sectionId } })); }; const openAIHelp = () => { setStore('isNotepadMode', false); - setCurrentView("help_ai"); + navigateToView("help_ai"); }; const handleMediaChange = (event: MediaQueryListEvent) => { @@ -92,13 +201,39 @@ const App: Component = () => { const handleLocChange = () => { setLocation(window.location.href); - const hash = window.location.hash; - if (hash === "#help" || hash.startsWith("#help/")) { - setCurrentView("help"); + if (isEmbed()) return; + + const routeState = parseHashRoute(window.location.hash); + if (routeState) { + if (routeState.view === "notes") { + setStore('isNotepadMode', true); + setSelectedNoteId(routeState.sectionId || null); + return; + } + + setStore('isNotepadMode', false); + setCurrentView(routeState.view); + return; + } + + const normalizedHash = normalizeHash(window.location.hash); + if (normalizedHash !== window.location.hash) { + const nextUrl = `${window.location.pathname}${window.location.search}${normalizedHash}`; + window.history.replaceState(window.history.state, "", nextUrl); + setLocation(window.location.href); + return; + } + + if (!window.location.hash) { + if (store.isNotepadMode) { + syncNotesRoute(selectedNoteId(), { replace: true }); + } else { + syncUrlToView(currentView(), { replace: true }); + } } }; - (window as any).setCurrentView = setCurrentView; + (window as any).setCurrentView = navigateToView; (window as any).openTasGridHelp = openHelpGuide; (window as any).openTasGridAIHelp = openAIHelp; @@ -186,9 +321,13 @@ const App: Component = () => { { }} />}> setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")} + onToggleAIHelp={() => navigateToView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")} aiHelpTip={aiHelpTip()} hideQuickEntry={currentView() === "help_ai" && !isDesktop()} > @@ -200,9 +339,9 @@ const App: Component = () => { - + - setCurrentView("settings")} /> + navigateToView("settings")} /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 04a928c..8c79839 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount, onCleanup } from "solid-js"; import { Sidebar, BottomNav, ContextSwitcher } from "./Navigation"; -import { store, setStore, activeTaskId, setActiveTaskId, requestImmediateNotesMetadataLoad } from "@/store"; +import { store, activeTaskId, setActiveTaskId, requestImmediateNotesMetadataLoad, fetchNoteById, updateStoreWithNote } from "@/store"; import { PanelLeftOpen, PanelLeftClose } from "lucide-solid"; const TaskDetail = lazy(() => import("./TaskDetail").then(m => ({ default: m.TaskDetail }))); @@ -15,6 +15,10 @@ interface LayoutProps { children: JSX.Element; currentView: string; setView: (v: string) => void; + selectedNoteId: string | null; + setSelectedNoteId: (id: string | null) => void; + onOpenTasks: (view?: string, options?: { replace?: boolean }) => void; + onOpenNotes: (noteId?: string | null, options?: { replace?: boolean }) => void; isAIHelpOpen?: boolean; onToggleAIHelp?: () => void; aiHelpTip?: string | null; @@ -25,21 +29,25 @@ export const Layout: Component = (props) => { const [isSidebarLocked, setIsSidebarLocked] = createSignal(true); const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false); const [isDropdownOpen, setIsDropdownOpen] = createSignal(false); + const [resolvingRoutedNoteId, setResolvingRoutedNoteId] = createSignal(null); - // Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView - const [selectedNoteId, setSelectedNoteId] = createSignal(null); + const setActiveNoteId = (id: string | null) => { + props.setSelectedNoteId(id); + if (store.isNotepadMode) { + props.onOpenNotes(id); + } + }; // Make it available globally for QuickEntry auto-tagging without circular deps createEffect(() => { - (window as any)._activeNoteId = selectedNoteId(); + (window as any)._activeNoteId = props.selectedNoteId; }); onMount(() => { const handleOpenNote = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail && detail.noteId) { - setStore('isNotepadMode', true); - setSelectedNoteId(detail.noteId); + props.onOpenNotes(detail.noteId); } }; window.addEventListener('open-note', handleOpenNote); @@ -65,12 +73,38 @@ export const Layout: Component = (props) => { }); createEffect(() => { - const noteId = selectedNoteId(); + const noteId = props.selectedNoteId; if (!noteId) return; + const noteExists = store.notes.some(note => note.id === noteId); - if (!noteExists) { - setSelectedNoteId(null); + if (noteExists) { + if (resolvingRoutedNoteId() === noteId) { + setResolvingRoutedNoteId(null); + } + return; } + + if (store.isNotesLoading || !store.hasLoadedNotesMetadata) return; + if (resolvingRoutedNoteId() === noteId) return; + + setResolvingRoutedNoteId(noteId); + void (async () => { + try { + const fetched = await fetchNoteById(noteId); + if (fetched) { + updateStoreWithNote(fetched); + return; + } + } catch (error) { + console.error("Failed to resolve routed note", error); + } + + if (props.selectedNoteId === noteId) { + setActiveNoteId(null); + props.onOpenNotes(null, { replace: true }); + } + setResolvingRoutedNoteId(current => current === noteId ? null : current); + })(); }); return ( @@ -95,7 +129,7 @@ export const Layout: Component = (props) => { class={cn( "hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-200 ease-in-out z-[50] overflow-hidden", isSidebarLocked() ? `${sidebarWidthClass()} translate-x-0` : `${sidebarWidthClass()} -translate-x-full`, - !isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border" + !isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !props.selectedNoteId)) && "translate-x-0 shadow-2xl ring-1 ring-border" )} onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)} onMouseLeave={() => { @@ -158,8 +192,8 @@ export const Layout: Component = (props) => { -
- +
+
@@ -208,7 +242,7 @@ export const Layout: Component = (props) => { "flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg", !store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground" )} - onClick={() => setStore('isNotepadMode', false)} + onClick={() => props.onOpenTasks()} > Tasks @@ -217,7 +251,7 @@ export const Layout: Component = (props) => { "flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg", store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground" )} - onClick={() => setStore('isNotepadMode', true)} + onClick={() => props.onOpenNotes(props.selectedNoteId)} > Notes @@ -253,7 +287,7 @@ export const Layout: Component = (props) => {
- +
diff --git a/src/views/HelpView.tsx b/src/views/HelpView.tsx index fbe54e9..96786d5 100644 --- a/src/views/HelpView.tsx +++ b/src/views/HelpView.tsx @@ -68,8 +68,20 @@ export const HelpView: Component = (props) => { if (!id) return; document.getElementById(`help-${id}`)?.scrollIntoView({ behavior, block: "start" }); }; - const getSectionFromHash = () => window.location.hash.startsWith("#help/") ? decodeURIComponent(window.location.hash.slice("#help/".length)) : undefined; - const jumpTo = (id: string) => { if (window.location.hash !== `#help/${id}`) window.location.hash = `#help/${id}`; scrollToSection(id); }; + const getSectionFromHash = () => { + if (window.location.hash.startsWith("#/help/")) { + return decodeURIComponent(window.location.hash.slice("#/help/".length)); + } + if (window.location.hash.startsWith("#help/")) { + return decodeURIComponent(window.location.hash.slice("#help/".length)); + } + return undefined; + }; + const jumpTo = (id: string) => { + const nextHash = `#/help/${encodeURIComponent(id)}`; + if (window.location.hash !== nextHash) window.location.hash = nextHash; + scrollToSection(id); + }; onMount(() => { const syncToHash = () => window.setTimeout(() => scrollToSection(getSectionFromHash(), "auto"), 50);