17 KiB
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 (calendar, periodic paths, recent files)
├── models/
│ └── bookmark.ts BookmarkItem + WaypointData interfaces
├── utils/
│ └── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
└── views/
└── waypoint-view.ts ItemView subclass — full sidebar rendering + interaction
Build: esbuild bundles src/main.ts → main.js. TypeScript strict, ESNext target.
Data persistence: Single data.json via Obsidian Plugin.loadData()/saveData(). Top-level keys: settings (merged with DEFAULT_SETTINGS), waypointData (bookmark tree). Recent files are in-memory only (lost on vault close).
Plugin Lifecycle
onload()
- Load data —
loadSettings()thenloadWaypointData(), both merge saved partials over defaults viaObject.assign({}, DEFAULT, partial). - Register view —
WAYPOINT_VIEW_TYPE = "waypoint-view"maps toWaypointViewfactory(leaf) => new WaypointView(leaf, this). - Settings tab —
WaypointSettingTabreceives the livesettingsobject +() => this.redrawAll()callback for live preview. - Register commands (see Commands section below).
- Register events —
file-open,vault:create,vault:delete,vault:rename. - Auto-open — On
onLayoutReady, if no existing leaves of the view type, opens one in the left sidebar. - Midnight refresh — 10-minute
setIntervalchecks ifnew Date().toDateString()changed; if so, callsredrawAll()(calendar day indicators refresh).
onunload()
Detaches all leaves of WAYPOINT_VIEW_TYPE.
Data Models
WaypointSettings
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[]
}
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 tags
}
BookmarkItem (recursive tree)
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[];
}
The bookmark tree is stored flat in data.json under waypointData.bookmarks but rendered recursively. Groups contain children; files/separators/spacers are leaf nodes.
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:
- Gets the active file's basename.
- Calls
detectPeriodType(basename)— regex matching:^\d{4}-\d{2}-\d{2}$→ daily^\d{4}-W\d{2}$→ weekly^\d{4}-\d{2}$→ monthly^\d{4}-Q[1-4]$→ quarterly^\d{4}$→ yearly
- Parses date via
moment(basename, format). - Adds ±1 period (quarters add ±3 months).
- Calls
openPeriodNotefor 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)
For a given period + moment date:
- Build filename:
date.format(periodSettings.nameFormat) + ".md". - Build full path: If
periodSettings.folderis set, prepend it; otherwise root. - Check existence:
vault.getFileByPath(fullPath). - 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 istype: {typeProperty}+date: YYYY-MM-DD.
- Try to read template at
- Open in new leaf:
workspace.getLeaf(false).openFile(file).
Event Handling
file-open
If settings.recentFiles.updateOn === 'file-open' → calls addToRecentFiles(file).
If updateOn === 'file-edit' → currently skipped (placeholder for future edit-detection).
vault:create / vault:delete
Both trigger broadcastRedraw() — tells all WaypointView instances to re-render (needed for calendar note indicators and stale recent file references).
vault:rename
- Updates
recentFilesentry if path matchesoldPath. - Calls
updateBookmarkPath(oldPath, newPath)— recursively scanswaypointData.bookmarksand updates anyfilePathmatchingoldPath.
Recent Files (in-memory)
recentFiles: { path: string; basename: string }[]
Update flow:
addToRecentFiles(file)— dedupes (removes existing entry), prepends to front, truncates tomaxItems.- Calls
broadcastRedraw().
Omitted paths/tags: Settings store regex patterns but the current addToRecentFiles does not filter by them — these are only exposed in the settings UI. The filtering is not yet wired up in the add logic.
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:
- Favorites (
renderFavorites) — bookmark tree - Recent Files (
renderRecentFiles) — flat list - 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.
- Day cells: Click opens daily note.
.other-monthdimmed..todayhas accent border..has-notegets a dot indicator (checked viavault.getFiles().some(f => f.basename === dateStr)). - Grid generation:
getMonthGrid(year, month, firstDayOfWeek)indate-utils.tsproduces up to 6 weeks, each with 7CalendarDayobjects containingmoment,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-activeclass if path matchesworkspace.getActiveFile(). - Remove button: × icon, appears on hover (
.waypoint-recent-remove), removes entry from array + saves settings + redraws. - Drag: Uses
app.dragManager.dragFile()for native Obsidian drag. - Hover preview: Triggers
hover-linkevent for Obsidian's page preview popup. - Context menu: "Open in new tab" + Obsidian's native
file-menuevent. - 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:
- Remove dragged item from its current position (recursive search).
- Insert at the target position (before or after based on cursor Y position relative to target midY).
- If target not found (edge case), push to root.
- Save + redraw.
Context menu (right-click):
- File items: "Open in new tab"
- File/Group items: "Rename" (opens
RenameModal), "Change icon" (opensIconSuggestModal) - "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):
- Remove item from current position.
- If targetGroupId: find group, set
item.indent = group.indent + 1, push to group's children. - If null (root): set
item.indent = 0, push towaypointData.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.jsonwith fallback tohttps://lucide.dev/api/tagsand a hardcodedFALLBACK_ICONSobject (~300 icons). - 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 getsmargin-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:
- Find the first day to display by subtracting
(firstOfMonth.day() - firstDayOfWeek + 7) % 7days from the 1st. - Iterate day-by-day, building 7-day weeks.
- Each
CalendarDaytracks:date(Moment),dayOfMonth,isToday,isCurrentMonth,isoWeekNumber. - Week's
weekNumber= first day's ISO week number. - Safety break after 6 weeks.
Settings Persistence
Settings and waypoint data share a single data.json via Obsidian's Plugin.loadData()/saveData():
// 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. This means they must not run concurrently (no locking — relies on Obsidian's synchronous save behavior).
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 events
- Bookmark add/remove/update
- Period navigation
- Settings changes (via
onSettingsChangecallback) - 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
- Recent files omittedPaths/omittedTags — Settings UI exposes these regex filters, but
addToRecentFiles()does not apply them. Files are never filtered out. updateOn: 'file-edit'— Thefile-openhandler returns early for this mode, but no edit-detection event is registered. Recent files never update in edit mode.- No virtual DOM — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar.
- Concurrent saves —
saveSettings()andsaveWaypointData()both re-read then writedata.json. Rapid sequential calls could race. - Recent files not persisted — Cleared on vault reload. Only bookmarks survive restarts.
- Note indicators scan all vault files —
hasNoteForDate()iteratesvault.getFiles()on every calendar render (called per day cell). For large vaults, this is O(days × files). Not cached. detectPeriodTypeuses hardcoded formats — The regex patterns inmain.tsdon't read fromsettings.*.nameFormat. If a user changes the name format to something non-standard, next/prev navigation breaks.- IconSuggestModal fetches from CDN — Network dependency on first icon picker open. Falls back to hardcoded list on failure.