Skip to content

Instantly share code, notes, and snippets.

@sergsoares
Created May 3, 2026 16:45
Show Gist options
  • Select an option

  • Save sergsoares/5cf13c13f8cd54673124205190d55f8a to your computer and use it in GitHub Desktop.

Select an option

Save sergsoares/5cf13c13f8cd54673124205190d55f8a to your computer and use it in GitHub Desktop.
Claude Code Web Planning

Plan: Joplin-Style Notebook Hierarchy

Context

The feature branch is currently based on main (old file-based, templ-template architecture). The develop branch has a completely different, more advanced architecture: VanJS SPA, REST JSON API, SQLite-based storage (modernc.org/sqlite), Playwright e2e tests. The feature must be built on top of develop.

The develop branch already has:

  • SQLite DB with notes, notes_fts, attachments, snapshots tables
  • REST API at /api/notes, /api/search, etc.
  • VanJS SPA frontend in staticfs/js/app.js + staticfs/js/api.js
  • Playwright e2e tests in e2e/

Goal: add notebook hierarchy as new tables in the same SQLite DB, new REST endpoints, VanJS sidebar/components, and Playwright e2e tests. Do NOT modify the notes table.

Working branch: claude/add-notebook-hierarchy-utGhO


Step 0: Rebase feature branch onto develop

The feature branch must be reset to origin/develop before any work begins:

git fetch origin develop
git reset --hard origin/develop

Architecture

Database (same DB file, new tables only)

Add to internal/store/migrate.go migrateSQL const:

CREATE TABLE IF NOT EXISTS notebooks (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT    NOT NULL,
    parent_id  INTEGER REFERENCES notebooks(id) ON DELETE SET NULL,
    created_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS note_notebooks (
    note_id     INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
    notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
    PRIMARY KEY (note_id, notebook_id)
);

-- Seed Default notebook (id=1, protected from deletion)
INSERT OR IGNORE INTO notebooks (id, name, parent_id, created_at)
VALUES (1, 'Default', NULL, strftime('%s','now') * 1000);

-- Assign all existing unassigned notes to Default on first migration
INSERT OR IGNORE INTO note_notebooks (note_id, notebook_id)
SELECT id, 1 FROM notes
WHERE id NOT IN (SELECT note_id FROM note_notebooks);

Using note_id INTEGER FK (not title) means renames preserve assignments automatically.


Implementation Steps

1. internal/store/migrate.go

Append the notebook SQL block above to the existing migrateSQL constant.

2. internal/store/store.go — new notebook methods

// Notebook model
type Notebook struct {
    ID        int64
    Name      string
    ParentID  *int64
    CreatedAt time.Time
}

// Store methods to add:
func (s *Store) ListNotebooks() ([]Notebook, error)
func (s *Store) GetNotebook(id int64) (Notebook, error)
func (s *Store) CreateNotebook(name string, parentID *int64) (int64, error)
func (s *Store) UpdateNotebook(id int64, name string, parentID *int64) error
func (s *Store) DeleteNotebook(id int64) error  // blocks id=1
func (s *Store) ListNotesByNotebook(notebookID int64) ([]noteListItem, error)
func (s *Store) AssignNoteToNotebook(noteID, notebookID int64) error  // INSERT OR REPLACE
func (s *Store) EnsureDefaultNotebook(noteID int64) error  // assign to 1 if unassigned

noteListItem is the existing struct already used by APINotesList.

3. handlers/api.go — new notebook handlers

Response types:

type apiNotebook struct {
    ID        int64  `json:"id"`
    Name      string `json:"name"`
    ParentID  *int64 `json:"parent_id"`
    CreatedAt int64  `json:"created_at"`
}

New handlers:

// GET  /api/notebooks          → []apiNotebook (flat list, frontend builds tree)
func (h *Handler) APIListNotebooks(w, r)

// POST /api/notebooks          → {name, parent_id?} → 201 + apiNotebook
func (h *Handler) APICreateNotebook(w, r)

// PUT  /api/notebooks/{id}     → {name, parent_id?} → 200 + apiNotebook
func (h *Handler) APIUpdateNotebook(w, r)

// DELETE /api/notebooks/{id}   → 204; 403 if id==1
func (h *Handler) APIDeleteNotebook(w, r)

// GET /api/notebooks/{id}/notes → [{title, updated_at}]
func (h *Handler) APINotebookNotes(w, r)

Update APICreateNote: after creating the note, read optional notebook_id from JSON body; if present and > 0, call AssignNoteToNotebook; else call EnsureDefaultNotebook.

Update apiCreateBody to add NotebookID *int64 \json:"notebook_id"``.

4. internal/httpserver/router.go — register new routes

Inside the protected group:

r.Get("/api/notebooks",              h.APIListNotebooks)
r.Post("/api/notebooks",             h.APICreateNotebook)
r.Put("/api/notebooks/{id}",         h.APIUpdateNotebook)
r.Delete("/api/notebooks/{id}",      h.APIDeleteNotebook)
r.Get("/api/notebooks/{id}/notes",   h.APINotebookNotes)

// SPA shell for notebook routes
r.Get("/notebooks/new",              h.AppShell)
r.Get("/notebooks/{id}",             h.AppShell)
r.Get("/notebooks/{id}/edit",        h.AppShell)

5. staticfs/js/api.js — notebook API client

Add these exported functions:

/** @returns {Promise<Array<{id:number,name:string,parent_id:number|null,created_at:number}>>} */
export function listNotebooks()

/** @param {{name:string, parent_id?:number|null}} body */
export function createNotebook(body)

/** @param {number} id, @param {{name:string, parent_id?:number|null}} body */
export function updateNotebook(id, body)

/** @param {number} id */
export function deleteNotebook(id)

/** @param {number} id */
export function getNotebookNotes(id)

6. staticfs/js/app.js — VanJS components

New global state:

const sidebarOpen = state(false);
const notebooks = state([]);  // flat list, refreshed on sidebar open

Helper: buildNotebookTree(flatList) Builds {id, name, parent_id, children:[]} tree from flat array.

New/updated functions:

hamburgerSvg() — 3-line SVG icon (like logoSvg())

Updated SiteHeader() — replace the <a class="header-logo"> element with:

button({
  id: "menu-btn",
  class: "btn btn-ghost header-menu-btn",
  "aria-label": "Open menu",
  onclick: () => {
    api.listNotebooks().then(d => notebooks.val = d).catch(() => {});
    sidebarOpen.val = true;
  }
}, hamburgerSvg())

NotebookTreeNode(node, depth) — recursive VanJS component for one notebook item + its children:

function NotebookTreeNode(node, depth = 0) {
  return div({ class: "nb-tree-item", style: `padding-left: ${depth * 1}rem` },
    a({
      class: "sidebar-link",
      href: appHref("/notebooks/" + node.id),
      onclick: (e) => { e.preventDefault(); sidebarOpen.val = false; navigateTo("/notebooks/" + node.id); }
    }, node.name),
    ...node.children.map(c => NotebookTreeNode(c, depth + 1))
  );
}

Sidebar() — full sidebar overlay component:

function Sidebar() {
  return div(
    div({  // backdrop
      class: () => "sidebar-backdrop" + (sidebarOpen.val ? " sidebar-backdrop-visible" : ""),
      onclick: () => sidebarOpen.val = false
    }),
    aside({  // panel
      class: () => "sidebar" + (sidebarOpen.val ? " sidebar-open" : "")
    },
      div({ class: "sidebar-header" },
        span({ class: "sidebar-title" }, "Notebooks"),
        button({ class: "btn btn-ghost sidebar-close-btn",
                 onclick: () => sidebarOpen.val = false }, "✕")
      ),
      nav({ class: "sidebar-nav" },
        a({ class: "sidebar-link sidebar-link-all",
            href: appHref("/"),
            onclick: (e) => { e.preventDefault(); sidebarOpen.val = false; navigateTo("/"); }
          }, "All Notes"),
        () => {
          const tree = buildNotebookTree(notebooks.val);
          return div({ class: "sidebar-notebooks" },
            ...tree.map(n => NotebookTreeNode(n, 0))
          );
        }
      ),
      div({ class: "sidebar-footer" },
        a({ class: "btn btn-secondary sidebar-new-notebook",
            href: appHref("/notebooks/new"),
            onclick: (e) => { e.preventDefault(); sidebarOpen.val = false; navigateTo("/notebooks/new"); }
          }, "+ New Notebook")
      )
    )
  );
}

NotebookView(notebookId) — like HomeView() but filtered:

  • Fetches api.getNotebookNotes(notebookId) and api.getNotebook(notebookId) (or use data from sidebar)
  • Shows notebook name as title
  • Shows filtered note list
  • Shows "+ New Note" → navigateTo("/notes/new?notebook_id=" + notebookId)
  • Shows "Edit" button → navigateTo("/notebooks/" + notebookId + "/edit")

NewNotebookView(parentId?) — form to create notebook:

  • Name input, parent selector (from notebooks state)
  • Submit → api.createNotebook(...) → navigate to /notebooks/{id}

EditNotebookView(notebookId) — form to edit:

  • Pre-fills name + parent
  • Submit → api.updateNotebook(...) → navigate to /notebooks/{id}
  • Delete button (disabled if id=1)

Update isInternalAppPath to add:

if (path === "/notebooks/new") return true;
if (path.startsWith("/notebooks/")) return true;

Update parseRouteFromPathAndSearch to add:

if (path === "/notebooks/new") {
  const parentId = sp.get("parent_id");
  return { name: "new-notebook", parentId: parentId ? parseInt(parentId) : null };
}
const nbEditMatch = path.match(/^\/notebooks\/(\d+)\/edit$/);
if (nbEditMatch) return { name: "edit-notebook", id: parseInt(nbEditMatch[1]) };
const nbMatch = path.match(/^\/notebooks\/(\d+)$/);
if (nbMatch) return { name: "notebook", id: parseInt(nbMatch[1]) };

Update MainBody() router switch:

case "notebook":       return r.id ? NotebookView(r.id) : HomeView();
case "new-notebook":   return NewNotebookView(r.parentId);
case "edit-notebook":  return r.id ? EditNotebookView(r.id) : HomeView();

Update App() to include Sidebar():

function App() {
  return div({ class: "app-layout" },
    SiteHeader(),
    Sidebar(),
    div({ class: "content-scroll" }, () => MainBody())
  );
}

Update NewNote flow: when route.val.name === "new" and a notebook_id query param exists, pass it through to the note creation so APICreateNote auto-assigns.

7. staticfs/style.css — append new sections

  • .header-menu-btn / .header-menu-icon — replaces logo styles
  • .sidebar-backdrop / .sidebar-backdrop-visible — dark overlay
  • .sidebar / .sidebar-open — slide-in panel (width 280px, transform)
  • .sidebar-header / .sidebar-title / .sidebar-close-btn
  • .sidebar-nav / .sidebar-link / .sidebar-link-all
  • .sidebar-footer / .sidebar-new-notebook
  • .sidebar-notebooks / .nb-tree-item — tree with depth indentation
  • .notebook-page-header / .notebook-page-title / .notebook-page-actions
  • .notebook-form / .notebook-select / .form-actions

8. e2e/notebooks_test.go — Playwright e2e tests (build tag e2e)

All tests use startTestApp(t) + newPlaywrightPage(t) (same patterns as existing tests).

Tests to implement:

// TestNotebookHierarchy_3Levels
// Creates a 3-level hierarchy and notes at each level:
// Root "Work" → child "Projects" → grandchild "snotes-go"
// Creates a note in each notebook, verifies each appears in its notebook page.

// TestNotebook_CreateAndAppearInSidebar
// Creates a notebook, opens sidebar, verifies it appears.

// TestNotebook_CreateNote_AssignedToNotebook
// Opens a notebook page, clicks "+ New Note", creates note,
// navigates back to notebook, verifies note is listed there.

// TestNotebook_DefaultNotebook
// Creates a note from the home page (no notebook context),
// verifies it appears in the Default notebook.

// TestNotebook_DeleteNonDefault
// Creates a notebook, deletes it, verifies it's gone from sidebar.

// TestNotebook_CannotDeleteDefault
// Tries to delete Default notebook, verifies it's blocked.

// TestNotebook_RenameNotebook
// Creates a notebook, edits it with a new name, verifies the new name shows.

Critical Files

File Change
internal/store/migrate.go Append notebooks + note_notebooks SQL
internal/store/store.go Add Notebook model + 7 store methods
handlers/api.go Add 5 notebook handlers; update CreateNote for notebook_id
internal/httpserver/router.go Register 5 new API routes + 3 SPA shell routes
staticfs/js/api.js Add 5 notebook API functions
staticfs/js/app.js Add Sidebar, NotebookTreeNode, NotebookView, NewNotebookView, EditNotebookView; update SiteHeader, MainBody, App, routing
staticfs/style.css Append sidebar + notebook CSS
e2e/notebooks_test.go New file: 7 Playwright e2e tests

Verification

  1. git reset --hard origin/develop — start from develop
  2. make generate && go build ./... — compiles cleanly
  3. go test ./internal/store/... ./handlers/... — unit tests pass
  4. make dev — server starts
  5. Hamburger button opens sidebar; All Notes link works; sidebar closes on backdrop click / Escape
  6. Create notebook → appears in sidebar tree
  7. Create sub-notebook → appears indented under parent
  8. 3-level hierarchy renders correctly
  9. Create note from notebook page → auto-assigned; appears in notebook view
  10. New note from home → auto-assigned to Default
  11. go test -tags=e2e ./e2e/... — all Playwright tests pass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment