# Waypoint Sidebar — Technical Documentation > Obsidian plugin (id: `waypoint-sidebar`) providing a unified sidebar with calendar, recent files, and custom bookmarks. Replaces separate Calendar and Bookmarks core plugins. --- ## Architecture Overview ``` src/ ├── main.ts Plugin entry — lifecycle, commands, events, data persistence ├── settings.ts Interface definitions + DEFAULT_SETTINGS constant ├── settings-tab.ts PluginSettingTab UI — tabs: calendar, periodic, recent, display, about ├── models/ │ └── bookmark.ts BookmarkItem + WaypointData interfaces ├── utils/ │ ├── date-utils.ts Moment.js helpers: period formatting, month grid, navigation │ └── path-utils.ts Pure path helper: rename remapping (no `obsidian` import, unit-testable) └── views/ └── waypoint-view.ts ItemView subclass — full sidebar rendering + interaction ``` **Build:** `npm run build` runs `tsc --noEmit` first, then esbuild bundles `src/main.ts` → `main.js`. The typecheck is a real gate — esbuild strips types without checking them, so without it type errors would ship. `npm run typecheck` runs the check alone; `npm run dev` (watch) stays ungated for speed. TypeScript strict-null + `noImplicitAny`, ESNext modules, ES6 target, `lib` floor at ES2017. **Data persistence:** Single `data.json` via Obsidian `Plugin.loadData()/saveData()`. Top-level keys: `settings` (merged with `DEFAULT_SETTINGS`), `waypointData` (bookmark tree **and** recent files). Recent files are persisted under `waypointData.recentFiles` and reloaded on startup; writes are debounced 300ms so rapid file opens collapse into one save. --- ## Plugin Lifecycle ### `onload()` 1. **Load data** — `loadSettings()` then `loadWaypointData()`, both merge saved partials over defaults via `Object.assign({}, DEFAULT, partial)`. 2. **Register view** — `WAYPOINT_VIEW_TYPE = "waypoint-view"` maps to `WaypointView` factory `(leaf) => new WaypointView(leaf, this)`. 3. **Settings tab** — `WaypointSettingTab` receives the live `settings` object + `() => this.redrawAll()` callback for live preview. 4. **Register commands** (see Commands section below). 5. **Register events** — `file-open`, `vault:create`, `vault:delete`, `vault:rename`, `vault:modify`. 6. **Auto-open** — On `onLayoutReady`, if no existing leaves of the view type, opens one in the left sidebar. 7. **Midnight refresh** — 10-minute `setInterval` checks if `new Date().toDateString()` changed; if so, calls `redrawAll()` (calendar day indicators refresh). ### `onunload()` Detaches all leaves of `WAYPOINT_VIEW_TYPE`. --- ## Data Models ### WaypointSettings ```typescript interface WaypointSettings { calendar: CalendarSettings; // firstDayOfWeek (0=Sun,1=Mon), showNoteIndicators daily: PeriodNoteSettings; weekly: PeriodNoteSettings; monthly: PeriodNoteSettings; quarterly: PeriodNoteSettings; yearly: PeriodNoteSettings; recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[], filterTags[] display: DisplaySettings; // px sizing for bookmark rows, fonts, icons, calendar cells } interface PeriodNoteSettings { folder: string; // e.g. "periodic/daily" templateFile: string; // path without .md, e.g. "Templates/Daily note" nameFormat: string; // moment.js format string typeProperty: string; // frontmatter type value, e.g. "daily-note" } interface CalendarSettings { firstDayOfWeek: number; // 0 = Sunday, 1 = Monday showNoteIndicators: boolean; // dot on days with existing .md files } interface RecentFilesSettings { maxItems: number; // default 50 updateOn: 'file-open' | 'file-edit'; // trigger mode omittedPaths: string[]; // regex patterns (one per line) omittedTags: string[]; // regex patterns for frontmatter/inline tags filterTags: string[]; // tags shown as filter pills (empty = auto-detect from frontmatter) } interface DisplaySettings { rowSize: number; // px, base height of bookmark items (18–40) rowSpacing: number; // px, gap between items (0–12) indentSize: number; // px, indent per depth level (8–32) fontSize: number; // px, font size for bookmark labels (10–18) iconSize: number; // px, icon size (12–24) calendarCellSize: number; // px, calendar day cell height (20–48) } ``` ### BookmarkItem (recursive tree) ```typescript interface BookmarkItem { id: string; // "bm-{timestamp}-{random4}" type: 'file' | 'group' | 'separator' | 'spacer'; label: string; // display text (empty for separator/spacer) filePath: string; // vault-relative path (empty for non-file types) icon: string; // Lucide icon name children: BookmarkItem[]; // nested items (groups contain children) collapsed: boolean; // group collapse state indent: number; // depth level (0 = root) } interface WaypointData { bookmarks: BookmarkItem[]; recentFiles: { path: string; basename: string }[]; } ``` The bookmark tree is stored under `waypointData.bookmarks` and rendered recursively. Groups contain children; files/separators/spacers are leaf nodes. `waypointData.recentFiles` holds the persisted recent-files list (mirrored into the live `plugin.recentFiles` array on load). --- ## Commands ### Period note navigation (with default hotkeys) | ID | Name | Hotkey | |---|---|---| | `waypoint-go-to-daily` | Go to daily note | `Mod+Shift+Alt+D` | | `waypoint-go-to-weekly` | Go to weekly note | `Mod+Shift+Alt+W` | | `waypoint-go-to-monthly` | Go to monthly note | `Mod+Shift+Alt+M` | | `waypoint-go-to-quarterly` | Go to quarterly note | `Mod+Shift+Alt+Q` | | `waypoint-go-to-yearly` | Go to yearly note | `Mod+Shift+Alt+Y` | All call `openPeriodNote(period, moment())` — opens or creates the current period's note. ### Next/Previous period (no default hotkeys) 10 commands generated in a loop: `waypoint-go-to-{next|prev}-{daily|weekly|monthly|quarterly|yearly}`. These call `navigatePeriodNote(direction)` which: 1. Gets the active file's basename. 2. Calls `detectPeriodType(basename)` — **settings-driven**, not hardcoded regexes. It reads `settings.{daily,weekly,monthly,quarterly,yearly}.nameFormat` and tries `moment(basename, nameFormat, true).isValid()` (strict parsing) in order day → week → month → quarter → year, returning the first match as `{ period, date }`. Periods with an empty `nameFormat` are skipped; returns `null` if nothing matches. Changing a name format in settings therefore keeps next/prev navigation working. 3. Adds ±1 period (quarters add ±3 months). 4. Calls `openPeriodNote` for the new date. ### Other commands | ID | Name | |---|---| | `waypoint-open-view` | Open Waypoint sidebar | | `waypoint-add-bookmark` | Add current file as Waypoint bookmark | --- ## Period Note Creation (`openPeriodNote`) ```typescript openPeriodNote( period: 'day' | 'week' | 'month' | 'quarter' | 'year', date: moment.Moment, leaf?: WorkspaceLeaf, ): Promise ``` Single unified opener — there is no separate `openPeriodNoteInLeaf`. For a given period + moment date: 1. **Build filename:** `date.format(periodSettings.nameFormat) + ".md"`. 2. **Build full path:** If `periodSettings.folder` is set, prepend it; otherwise root. 3. **Check existence:** `vault.getFileByPath(fullPath)`. 4. **If not found, create:** - Try to read template at `periodSettings.templateFile + ".md"`. - If template exists → `vault.create(fullPath, templateContent)`. - If no template → `vault.create(fullPath, minimalFrontmatter)` where frontmatter is `type: {typeProperty}` + `date: YYYY-MM-DD`. 5. **Open:** in `leaf` when one is supplied, otherwise in `workspace.getLeaf(false)`. Middle-click handlers in the view pass an explicit tab leaf: `openPeriodNote(period, date, this.app.workspace.getLeaf('tab'))`. --- ## Event Handling ### `file-open` Tracks the opened file into recent files **only** when `settings.recentFiles.updateOn === 'file-open'`. ### `vault:modify` Tracks the modified file (guarded to `TFile`) into recent files **only** when `updateOn === 'file-edit'`. Exactly one of the two modes is active at a time, so `'file-edit'` is a working mode rather than a no-op. ### `vault:create` / `vault:delete` Both update the markdown-basename set used for calendar note indicators, then trigger `broadcastRedraw()` — telling all WaypointView instances to re-render (needed for the indicators and for stale recent file references). ### `vault:rename` 1. Updates the markdown-basename set (old basename out, new basename in). 2. Remaps the `recentFiles` entry through `remapRenamedPath(entry.path, oldPath, newPath)`. 3. Remaps every bookmark `filePath` through the same helper. `remapRenamedPath(path, oldPath, newPath)` (in `src/utils/path-utils.ts`) returns the updated path when `path` **is** `oldPath` (a plain file rename) *or* is nested under it (`path.startsWith(oldPath + '/')`, i.e. a folder rename/move), and `null` when the path is unaffected. This is why moving a folder no longer orphans the bookmarks inside it. The helper deliberately imports nothing from `'obsidian'` so it stays pure and unit-testable in plain node. --- ## Recent Files (persisted) ```typescript recentFiles: { path: string; basename: string }[] ``` Backed by `waypointData.recentFiles` in `data.json`, so the list survives vault reload. `loadWaypointData()` restores it and re-applies the current `maxItems` limit (in case the setting shrank since the last save). **Update flow:** 1. `addToRecentFiles(file)` — omission check, then dedupes (removes existing entry), prepends to front, truncates to `maxItems`. 2. `persistRecentFiles()` — mirrors the array into `waypointData.recentFiles` and schedules a debounced (300ms) `saveWaypointData()`. 3. Calls `broadcastRedraw()`. **Omitted paths/tags:** Both filters are applied in `addToRecentFiles`. `omittedPaths` entries are treated as regexes tested against `file.path`; `omittedTags` entries are regexes tested against the file's tags, read via `getAllTags(metadataCache.getFileCache(file))` with the leading `#` stripped. An invalid regex is skipped rather than throwing. **`enforceRecentFilesLimit()`:** Called from the settings-change callback — trims the list when `maxItems` is lowered and persists the result. **On file not found:** When clicking a recent file that no longer exists, `focusFile()` shows a Notice and removes the stale entry from `recentFiles`. --- ## Sidebar View (`WaypointView`) Extends `ItemView`. View type: `"waypoint-view"`, icon: `compass`. ### Rendering (`redraw`) Called on every change. Full DOM rebuild — `contentEl.empty()` then three sections in order: 1. **Favorites** (`renderFavorites`) — bookmark tree 2. **Recent Files** (`renderRecentFiles`) — flat list 3. **Calendar** (`renderCalendar`) — month grid The last section (`waypoint-section:last-child`) gets `margin-top: auto`, pushing it to the bottom of the sidebar. ### Calendar Panel **State:** `currentDisplayMonth` (0-indexed), `currentDisplayYear` — allows navigating months independently of today. **Layout:** ``` ┌────────────────────────────────────┐ │ Q2 June 2026 ◀ Today ▶ │ ← breadcrumb (clickable) + nav ├──┬───┬───┬───┬───┬───┬───┬───┤ │24│sun│mon│tue│wed│thu│fri│sat│ ← header row + week# col ├──┼───┼───┼───┼───┼───┼───┼───┤ │25│ 1 │ 2 │ 3 │... ← day cells (clickable) └──┴───┴───┴───┴───┴───┴───┴───┘ ``` - **Breadcrumb:** Q-label, month name, year — each clickable to open that period note. - **Nav buttons:** ◀/▶ shift month ±1. "Today" resets to current month. - **Week number column:** Clicking a week number opens the weekly note for that week's Monday; middle-clicking opens it in a new tab. - **Day cells:** Click opens daily note, middle-click opens it in a new tab. `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator, decided by the synchronous O(1) `plugin.hasNoteForDate(dateStr)` (a `Set` lookup — no per-cell vault scan). - **Grid generation:** `getMonthGrid(year, month, firstDayOfWeek)` in `date-utils.ts` produces up to 6 weeks, each with 7 `CalendarDay` objects containing `moment`, `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`. ### Recent Files Panel Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the file explorer's CSS classes (`tree-item`, `nav-file-title`, `nav-file-title-content`). **Per-file features:** - **Active indicator:** `.is-active` class if path matches `workspace.getActiveFile()`. - **Remove button:** × icon, appears on hover (`.waypoint-recent-remove`), drops the entry from `plugin.recentFiles`, calls `persistRecentFiles()`, then redraws. - **Drag:** Uses `app.dragManager.dragFile()` for native Obsidian drag. - **Hover preview:** Triggers `hover-link` event for Obsidian's page preview popup. - **Context menu:** "Open in new tab" + Obsidian's native `file-menu` event. - **Click:** `Keymap.isModEvent(event)` determines if opening in new leaf; otherwise same leaf. - **Middle click:** Opens in new tab. ### Bookmarks/Favorites Panel **Empty state:** Right-click on "Waypoint Bookmarks" header shows add menu: - Add current file → `addBookmark(path, basename, 'file')` - New group → `addBookmark('', 'New Group', 'group', 'folder')` - Add separator → `addBookmark('', '', 'separator')` - Add spacer → `addBookmark('', '', 'spacer')` **Rendering:** `renderBookmarkList(container, items, depth)` recursively renders items with `paddingLeft: 8 + depth * 16px` for indentation. **Item types:** | Type | Render | Behavior | |---|---|---| | `file` | icon + label | Click opens file. Tooltip = filePath. | | `group` | chevron + icon + label | Click toggles `collapsed`. Children rendered in nested `waypoint-bookmark-children` div (hidden when collapsed). | | `separator` | horizontal line (::after pseudo-element) | No click handler. Context menu for delete. | | `spacer` | 14px empty div | No click handler. Context menu for delete. | **Drag-and-drop:** All bookmark items are draggable. Drop indicators show a 3px accent border above/below the target. On drop: 1. Remove dragged item from its current position (recursive search). 2. Insert at the target position (before or after based on cursor Y position relative to target midY). 3. If target not found (edge case), push to root. 4. Save + redraw. **Context menu (right-click):** - File items: "Open in new tab" - File/Group items: "Rename" (opens `RenameModal`), "Change icon" (opens `IconSuggestModal`) - "Move to group" submenu: Lists all available groups (excluding self + descendants) + "(Root)" for ungrouping - Group items: "Expand/Collapse", "Add bookmark here", "New sub-group" - All items: "Insert separator above/below", "Insert spacer above/below", "Remove" **Move to group (`moveBookmarkToGroup`):** 1. Remove item from current position. 2. If targetGroupId: find group, set `item.indent = group.indent + 1`, push to group's children. 3. If null (root): set `item.indent = 0`, push to `waypointData.bookmarks`. **Rename modal (`RenameModal`):** Simple Modal with text input + Cancel/Save buttons. Enter key submits. **Icon picker (`IconSuggestModal`):** Modal with: - Live preview of selected icon. - Search input with 60ms debounce. - Grid of matching icons (max 80 shown), loaded from `https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json` with fallback to `https://lucide.dev/api/tags` and a hardcoded `FALLBACK_ICONS` object (~300 icons). The fetch result is cached for the session, so at most one network round-trip happens no matter how often the picker is opened. - Click to select, "No icon" link to clear, Save/Cancel buttons. --- ## CSS Architecture (`styles.css`) All classes prefixed with `waypoint-`. Uses Obsidian CSS variables throughout: - `--font-ui-small`, `--font-ui-medium`, `--font-semibold`, `--font-medium`, `--font-light` - `--text-muted`, `--text-faint`, `--text-accent`, `--text-on-accent` - `--background-modifier-border`, `--background-modifier-active-hover`, `--background-primary`, `--background-secondary` - `--interactive-accent` - `--cursor` (for custom cursor support) Key layout: - `.waypoint-view` — flex column, `overflow-y: auto`, 8px padding. - `.waypoint-section` — `flex-shrink: 0`, 16px bottom margin. Last section gets `margin-top: auto` (pins calendar to bottom). - `.waypoint-section-header` — uppercase, muted, with bottom border. - Calendar table — `table-layout: fixed`, `border-collapse: collapse`. - `.waypoint-day.today` — accent color text + 1px accent border. - `.waypoint-day.has-note::after` — 4px dot indicator. - `.waypoint-bm-chevron` — `rotate(-90deg)` on collapsed groups. - `.waypoint-bm-drop-line` / `.waypoint-bm-drop-below` — 3px accent border for drag indicators. --- ## Date Utilities (`date-utils.ts`) All use Obsidian's bundled `moment` (not the npm package). | Function | Signature | Returns | |---|---|---| | `getPeriodInfo(date)` | `Moment → PeriodInfo` | `{day, week, month, quarter, year}` strings for the date | | `getQuarterString(date)` | `Moment → string` | e.g. `"2026-Q3"` | | `getTodayPeriod()` | `() → PeriodInfo` | Current date's period info | | `getMonthGrid(year, month, firstDayOfWeek)` | `number, number, number → CalendarWeek[]` | Up to 6 weeks, each with 7 `CalendarDay` objects | | `formatDateLabel(date)` | `Moment → string` | `"June 1st, 2026"` | | `formatMonthLabel(date)` | `Moment → string` | `"June 2026"` | | `formatQuarterLabel(date)` | `Moment → string` | `"Q3 2026"` | | `navigateDate(date, period, delta)` | `Moment, period, number → Moment` | Clones + adds delta (quarters: delta×3 months) | | `formatPeriodName(date, format)` | `Moment, string → string` | `date.format(format)` wrapper | | `getISOWeek(date)` | `Moment → number` | `date.isoWeek()` | **`getMonthGrid` algorithm:** 1. Find the first day to display by subtracting `(firstOfMonth.day() - firstDayOfWeek + 7) % 7` days from the 1st. 2. Iterate day-by-day, building 7-day weeks. 3. Each `CalendarDay` tracks: `date` (Moment), `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`. 4. Week's `weekNumber` = first day's ISO week number. 5. Safety break after 6 weeks. --- ## Settings Persistence Settings and waypoint data share a single `data.json` via Obsidian's `Plugin.loadData()/saveData()`: ```typescript // loadData() returns: { settings: { calendar: {...}, daily: {...}, ... }, waypointData: { bookmarks: [...] } } ``` Both `loadSettings()` and `loadWaypointData()` read from the same file, merging partials over defaults. `saveSettings()` and `saveWaypointData()` each re-read the full data, update their key, and write back — so they are serialized through a single-writer save queue: each call chains onto the previous one's promise instead of racing it. Recent-file writes additionally go through the 300ms debounce in `persistRecentFiles()`. --- ## Redraw Mechanism `broadcastRedraw()` iterates all leaves of `WAYPOINT_VIEW_TYPE` and calls `view.redraw()` on each. `redraw()` is a full DOM rebuild (no virtual DOM, no diffing). This is triggered by: - File open/create/delete/rename/modify events - Bookmark add/remove/update - Period navigation - Settings changes (via `onSettingsChange` callback) - Midnight detection (10-min interval) The sidebar is re-rendered from scratch on every change. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker. --- ## Known Limitations / Gaps 1. **No virtual DOM** — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker. 2. **IconSuggestModal fetches from CDN** — Network dependency for the Lucide tag list. The fetch happens at most once per session (the result is cached) and falls back to the hardcoded `FALLBACK_ICONS` list on failure, but a first-open with no network still degrades to that reduced list.