Notes Sidebar View — Design

Date: 2026-04-24 Status: Approved for planning

Summary

Add a second view mode to /notes: a Notion-style sidebar (default) alongside the existing card view. Notes gain an emoji field and a 2-level parent/child hierarchy. Users reorder and re-nest notes via drag-and-drop. A shared emoji picker replaces the per-feature icon grids in habits, goals, and countdowns.

Goals

  • Sidebar view as the default on /notes, with a toggle to switch to card view.
  • Emoji on every note, rendered in both sidebar and card views.
  • 2-level hierarchy (root + one level of children) with drag-and-drop reorder and re-nest.
  • Title + rich-text content search in the sidebar.
  • A single searchable emoji picker used across notes, habits, goals, and countdowns.

Non-goals

  • Inline editing in the main pane (editing continues via the modal form).
  • Grandchild nesting.
  • Mobile split-view (mobile uses the card view).
  • Cross-device sync of sidebar collapse/expand state or selected view (localStorage only).
  • Removing Habit::ICONS / Goal::ICONS constants (view-level only; constants stay for now).

Data model

Single migration on notes:

Column Type Null Default Notes
emoji string yes null Free-form emoji character (e.g. 📖).
parent_id integer yes null Self-FK, on_delete: :cascade.
position integer no 0 Per-sibling ordering.

Indexes:

  • [user_id, parent_id, position] (tree ordering queries)
  • [parent_id] (children lookup)

Note model additions

  • belongs_to :parent, class_name: "Note", optional: true
  • has_many :children, -> { order(:position) }, class_name: "Note", foreign_key: :parent_id, dependent: :destroy
  • scope :roots, -> { where(parent_id: nil) }
  • Validation: parent_id must not equal id and must point to a root note (parent's own parent_id must be nil) — enforces the 2-level depth cap.
  • Validation: if parent_id is set, this note must itself have no children (would create grandchildren).
  • Validation: parent must belong to the same user.
  • Permit params adds :emoji, :parent_id.

Note::Mover (namespaced model class, app/models/note/mover.rb)

Handles drag-and-drop moves atomically.

Note::Mover.call(note:, parent_id:, position:)
  • Authorizes same-user parent.
  • Rejects: self-parenting, moves that would violate 2-level cap (moving a note that has children under another parent), cross-user parents.
  • Resequences siblings under the old and new parent inside a transaction.
  • Returns the updated note or raises.

Shared emoji picker

Files

  • app/helpers/emoji_catalog.rb — a module exposing EmojiCatalog::ALL as an array of hashes:
    { emoji: "🎯", name: "target", keywords: "goal aim bullseye", category: "symbols" }
    

    ~80–100 entries grouped in categories: Smileys, People, Activities, Objects, Food, Travel, Symbols, Nature. Must be a strict superset of the current Habit::ICONS, Goal::ICONS, and the countdown inline list.

  • app/views/shared/_emoji_picker.html.erb — partial with a trigger button (shows current emoji, or a + placeholder) and a popover containing a search input and a filtered grid.

  • app/javascript/controllers/emoji_picker_controller.js — Stimulus controller:
    • Toggles the popover on trigger click.
    • Filters grid by matching name and keywords (case-insensitive substring).
    • Writes the selected emoji into a hidden form field and updates the trigger display.
    • Closes on selection, outside click, or Esc.

Partial API

<%= render "shared/emoji_picker", form: form, field: :emoji, current: @note.emoji %>

Adoption

Replace the inline icon/emoji radio grids in:

  • app/views/habits/_form.html.erb
  • app/views/goals/_form.html.erb
  • app/views/countdowns/_form.html.erb
  • app/views/notes/_form.html.erb (new usage)

Model-level ICONS constants are kept (no downstream impact on views) for now.

Notes index — view toggle & card view

Controller

NotesController#index loads both data shapes every request (view switch is client-side):

  • @roots — root notes (parent_id: nil), with children eager-loaded, pinned-first — for sidebar view.
  • @notes — existing flat list for card view (unchanged).

View structure

header (title + view toggle + archive link + new button)
├─ cards container (data-notes-view-target="cards")
└─ sidebar container (data-notes-view-target="sidebar")

notes_view_controller.js

  • On connect:
    • If viewport ≤ 768 px (media query), forces "cards" and ignores localStorage.
    • Otherwise reads localStorage["notes.view"] (default "sidebar").
  • Toggles visibility via CSS classes.
  • Persists on user toggle.

Toggle UI

Two small icon buttons in the header (cards icon, sidebar icon). Active one is highlighted.

Card view changes

Prefix each card's title with note.emoji (when present) inline before the <h3>. No structural changes.

Notes index — sidebar view

Layout (desktop, ≥768 px)

┌─ 280px sidebar ──┬─ main pane (flex-1) ───┐
│ [Search]         │                         │
│ ─ Pinned ▾       │  turbo-frame#note_pane  │
│   📌 Metas 2026  │  (renders notes/show    │
│ ─ Notes ▾        │   or empty state)       │
│   📖 Book Notes  │                         │
│    └ Atomic Hab. │                         │
│    └ Deep Work   │                         │
│   📝 Weekly Rev. │                         │
│ [+ New note]     │                         │
└──────────────────┴─────────────────────────┘

Partials

  • notes/_sidebar.html.erb — sidebar shell (search, pinned group, roots group, new-note button).
  • notes/_tree_item.html.erb — recursive partial for one note + its children (disclosure arrow, emoji, title, hover-revealed + and ).
  • notes/_pane.html.erb — turbo-frame id="note_pane" wrapping the existing show content or an empty state.

Interactions

  • Clicking a sidebar item navigates to /notes/:id with data-turbo-frame="note_pane". Main pane updates; sidebar persists; URL and back button behave naturally.
  • Sidebar highlights the active item (compare params[:id]).
  • Parents with children show a disclosure arrow; collapsed state stored in localStorage keyed by note id.
  • Empty pane state when no note is selected.

Search (GET /notes?q=<term>)

  • New scope Note.search(term) → matches title ILIKE :q OR action_text_rich_texts.body ILIKE :q, via joins(:rich_text_content).
  • Input in the sidebar debounced at 300 ms, Turbo-frame swap of the sidebar tree region only.
  • When q is present: show a flat filtered list (no hierarchy, no Pinned/Notes groups). Otherwise show the tree.
  • Title matches highlighted via <mark>.

Per-item menu (reuses dropdown_controller.js)

  • Rename (opens existing edit modal)
  • Pin / Unpin
  • Archive
  • Delete (with confirm)

Hover + on parent items

Opens the new-note modal with parent_id pre-filled.

Pinned section

  • Displayed at the top of the sidebar, collapsible.
  • Mirrors the current pinned_first ordering.
  • Not drag-sortable — pin/unpin via the menu moves items in/out.

Drag-and-drop

Library: sortablejs (already installed; used in kanban_controller.js).

notes_tree_controller.js

Attached to the sidebar root. On connect:

  • Initializes a Sortable on the root list AND on each children list, all with group: "notes-tree" so items can move between lists.
  • fallbackOnBody: true and a small emptyInsertThreshold so a parent can accept a first child.
  • onMove callback vetoes drops that would create a grandchild (dropping onto a list whose parent is itself a child).

onEnd handler

POSTs to the new endpoint:

POST /notes/:id/move
Body: { parent_id: <int or null>, position: <int> }
→ 204 No Content on success
→ 422 on validation failure; the controller reverts the DOM move

NotesController#move

  • Authorizes with NotePolicy#move? (same as update?: owner only).
  • Delegates to Note::Mover.call(...).
  • Responds head :no_content on success, head :unprocessable_entity on ActiveRecord::RecordInvalid.

2-level cap enforcement

  • Root → can become a child of another root, or reorder at root.
  • Child → can reorder within its parent, move between parents, or become a root.
  • A child with no descendants cannot be dropped onto another child (creates a grandchild). Blocked in onMove client-side and in Note::Mover server-side.
  • A root WITH children cannot become a child of another root (would create a grandchild transitively). Same enforcement.

Routes

resources :notes do
  member do
    post :toggle_pin
    post :archive
    post :move     # new
  end
end

Authorization

NotePolicy#move?owner? (alias of update?).

Testing

Minitest, per project convention.

  • test/models/note_test.rb
    • Emoji persistence.
    • Parent/child associations.
    • Cycle prevention (note cannot be its own parent).
    • Depth cap: rejecting grandchildren.
    • destroy cascades to children.
  • test/models/note/mover_test.rb
    • Reorder within parent.
    • Move between parents.
    • Move to root.
    • Reject cross-user parent.
    • Reject self-parenting.
    • Reject depth violations (both directions).
    • Resequencing correctness under/around the move.
  • test/controllers/notes_controller_test.rb
    • #index unchanged for card view consumers.
    • #move happy path → 204; unauthorized → 403; invalid → 422.
    • ?q=<term> filters by title and by rich-text body.
  • test/system/notes_test.rb
    • Toggle flips views; localStorage persists selection across reloads.
    • Emoji renders in sidebar item and as card prefix.
    • Sidebar item click updates the main pane without a full reload.
    • Search filters the tree and highlights matches.
    • (Drag-and-drop can be stubbed via a direct POST to /notes/:id/move rather than simulating mouse events.)

i18n

Add keys to config/locales/en.yml and config/locales/pt.yml:

  • notes.sidebar.search_placeholder
  • notes.sidebar.pinned
  • notes.sidebar.notes
  • notes.sidebar.new_note
  • notes.sidebar.empty_pane
  • notes.toggle.cards
  • notes.toggle.sidebar
  • shared.emoji_picker.search_placeholder
  • shared.emoji_picker.no_results
  • shared.emoji_picker.remove

Seeds

Update db/seeds.rb notes block:

  • Add emoji to each seeded note.
  • Create one parent-with-children group (e.g. a "Book Notes" root with "Atomic Habits" and "Deep Work" as children) so the sidebar view has content to demonstrate.

Out of scope (explicit)

  • Inline (click-to-edit) main-pane editor.
  • Grandchild nesting (2-level cap is intentional).
  • Mobile split-view.
  • Cross-device sync of view preference or collapse state.
  • Retiring Habit::ICONS / Goal::ICONS model constants.