feat: sort notes by property in relationship subsections

This commit is contained in:
lucaronin
2026-02-21 17:14:18 +01:00
4 changed files with 6389 additions and 49 deletions

5987
design/sort-notes.pen Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { NoteList } from './NoteList'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NoteList, getSortComparator } from './NoteList'
import type { VaultEntry, SidebarSelection } from '../types'
const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
@@ -247,3 +247,201 @@ describe('NoteList', () => {
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
})
})
describe('getSortComparator', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md',
filename: 'test.md',
title: 'Test',
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 100,
snippet: '',
relationships: {},
icon: null,
color: null,
...overrides,
})
it('sorts by modified date descending', () => {
const a = makeEntry({ title: 'A', modifiedAt: 1000 })
const b = makeEntry({ title: 'B', modifiedAt: 3000 })
const c = makeEntry({ title: 'C', modifiedAt: 2000 })
const sorted = [a, b, c].sort(getSortComparator('modified'))
expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A'])
})
it('sorts by created date descending', () => {
const a = makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 })
const b = makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 })
const c = makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 })
const sorted = [a, b, c].sort(getSortComparator('created'))
expect(sorted.map((e) => e.title)).toEqual(['A', 'C', 'B'])
})
it('sorts by created date, falling back to modifiedAt when createdAt is null', () => {
const a = makeEntry({ title: 'A', createdAt: null, modifiedAt: 5000 })
const b = makeEntry({ title: 'B', createdAt: 2000, modifiedAt: 1000 })
const sorted = [a, b].sort(getSortComparator('created'))
expect(sorted.map((e) => e.title)).toEqual(['A', 'B'])
})
it('sorts by title alphabetically', () => {
const a = makeEntry({ title: 'Zebra' })
const b = makeEntry({ title: 'Alpha' })
const c = makeEntry({ title: 'Middle' })
const sorted = [a, b, c].sort(getSortComparator('title'))
expect(sorted.map((e) => e.title)).toEqual(['Alpha', 'Middle', 'Zebra'])
})
it('sorts by status (Active > Paused > Done > null)', () => {
const a = makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 })
const b = makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 })
const c = makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 })
const d = makeEntry({ title: 'Paused', status: 'Paused', modifiedAt: 1000 })
const sorted = [a, b, c, d].sort(getSortComparator('status'))
expect(sorted.map((e) => e.title)).toEqual(['Active', 'Paused', 'Done', 'NoStatus'])
})
it('sorts by status with modified date as tiebreaker', () => {
const a = makeEntry({ title: 'OlderActive', status: 'Active', modifiedAt: 1000 })
const b = makeEntry({ title: 'NewerActive', status: 'Active', modifiedAt: 3000 })
const sorted = [a, b].sort(getSortComparator('status'))
expect(sorted.map((e) => e.title)).toEqual(['NewerActive', 'OlderActive'])
})
})
describe('NoteList sort controls', () => {
beforeEach(() => {
try { localStorage.removeItem('laputa-sort-preferences') } catch { /* noop */ }
})
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md',
filename: 'test.md',
title: 'Test',
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 100,
snippet: '',
relationships: {},
icon: null,
color: null,
...overrides,
})
it('shows sort button in note list header for flat view', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
})
it('shows sort dropdown per relationship subsection in entity view', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
})
it('opens sort menu on click and shows all options', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-created')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-title')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-status')).toBeInTheDocument()
})
it('changes sort order when an option is selected', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
// Default sort: by modified (Zebra first)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
// Switch to title sort
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-title'))
// Now should be alphabetical
titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
expect(titles).toEqual(['Alpha', 'Middle', 'Zebra'])
})
it('closes sort menu after selecting an option', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('sort-option-title'))
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
})
it('sorts relationship subsection entries when sort option changed', () => {
// Create an entity with children that have different titles
const parent = makeEntry({
path: '/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
})
const child1 = makeEntry({
path: '/child1.md',
filename: 'child1.md',
title: 'Zebra Note',
belongsTo: ['[[parent]]'],
modifiedAt: 3000,
})
const child2 = makeEntry({
path: '/child2.md',
filename: 'child2.md',
title: 'Alpha Note',
belongsTo: ['[[parent]]'],
modifiedAt: 1000,
})
const entries = [parent, child1, child2]
render(
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
// Default sort: by modified — Zebra Note (3000) before Alpha Note (1000)
let titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent)
expect(titles).toEqual(['Zebra Note', 'Alpha Note'])
// Switch to title sort
fireEvent.click(screen.getByTestId('sort-button-Children'))
fireEvent.click(screen.getByTestId('sort-option-title'))
// Now alphabetical: Alpha before Zebra
titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent)
expect(titles).toEqual(['Alpha Note', 'Zebra Note'])
})
})

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, memo } from 'react'
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
// Virtuoso removed — flat list rendering used instead
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
import { cn } from '@/lib/utils'
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
import {
MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, StackSimple,
ArrowsDownUp, Check,
} from '@phosphor-icons/react'
import type { ComponentType, SVGAttributes } from 'react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -93,6 +94,57 @@ export function sortByModified(a: VaultEntry, b: VaultEntry): number {
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
export type SortOption = 'modified' | 'created' | 'title' | 'status'
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'modified', label: 'Modified' },
{ value: 'created', label: 'Created' },
{ value: 'title', label: 'Title' },
{ value: 'status', label: 'Status' },
]
const STATUS_ORDER: Record<string, number> = {
Active: 0,
Paused: 1,
Done: 2,
Finished: 3,
}
export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number {
switch (option) {
case 'modified':
return sortByModified
case 'created':
return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0)
case 'title':
return (a, b) => a.title.localeCompare(b.title)
case 'status':
return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return sa - sb
return sortByModified(a, b)
}
}
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
function loadSortPreferences(): Record<string, SortOption> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
function saveSortPreferences(prefs: Record<string, SortOption>) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
} catch { /* ignore */ }
}
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
const stem = entity.filename.replace(/\.md$/, '')
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
@@ -221,14 +273,92 @@ export function filterEntries(entries: VaultEntry[], selection: SidebarSelection
}
}
function SortDropdown({
groupLabel,
current,
onChange,
}: {
groupLabel: string
current: SortOption
onChange: (groupLabel: string, option: SortOption) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
className={cn(
"flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent",
open && "bg-accent text-foreground"
)}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
title={`Sort by ${current}`}
data-testid={`sort-button-${groupLabel}`}
>
<ArrowsDownUp size={12} />
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
style={{ width: 130, padding: 4 }}
data-testid={`sort-menu-${groupLabel}`}
>
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
className={cn(
"flex w-full items-center gap-1.5 rounded px-2 text-[12px] text-popover-foreground hover:bg-accent",
opt.value === current && "bg-accent font-medium"
)}
style={{ height: 28, border: 'none', cursor: 'pointer', background: opt.value === current ? 'var(--accent)' : 'transparent' }}
onClick={(e) => {
e.stopPropagation()
onChange(groupLabel, opt.value)
setOpen(false)
}}
data-testid={`sort-option-${opt.value}`}
>
{opt.value === current
? <Check size={12} />
: <span style={{ width: 12, height: 12, display: 'inline-block' }} />
}
{opt.label}
</button>
))}
</div>
)}
</div>
)
}
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const [sortPrefs, setSortPrefs] = useState<Record<string, SortOption>>(loadSortPreferences)
const isEntityView = selection.kind === 'entity'
const isSectionGroup = selection.kind === 'sectionGroup'
const handleSortChange = useCallback((groupLabel: string, option: SortOption) => {
setSortPrefs((prev) => {
const next = { ...prev, [groupLabel]: option }
saveSortPreferences(next)
return next
})
}, [])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev)
@@ -264,9 +394,11 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
[entries, selection, modifiedFiles, isEntityView]
)
const listSort = sortPrefs['__list__'] ?? 'modified'
const sorted = useMemo(
() => isEntityView ? [] : [...filtered].sort(sortByModified),
[filtered, isEntityView]
() => isEntityView ? [] : [...filtered].sort(getSortComparator(listSort)),
[filtered, isEntityView, listSort]
)
const query = search.trim().toLowerCase()
@@ -385,32 +517,48 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
) : (
groups.map((group) => {
const isGroupCollapsed = collapsedGroups.has(group.label)
const groupSort = sortPrefs[group.label] ?? 'modified'
const sortedEntries = [...group.entries].sort(getSortComparator(groupSort))
return (
<div key={group.label}>
<button
className="flex w-full items-center justify-between border-none bg-muted cursor-pointer"
<div
className="flex w-full items-center justify-between bg-muted"
style={{ height: 32, padding: '0 16px' }}
onClick={() => toggleGroup(group.label)}
>
<span className="flex items-center gap-1.5">
<button
className="flex flex-1 items-center gap-1.5 border-none bg-transparent cursor-pointer p-0"
onClick={() => toggleGroup(group.label)}
>
<span className="font-mono-label text-muted-foreground">
{group.label}
</span>
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</button>
<span className="flex items-center gap-1.5">
<SortDropdown
groupLabel={group.label}
current={groupSort}
onChange={handleSortChange}
/>
<button
className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground"
onClick={() => toggleGroup(group.label)}
>
{isGroupCollapsed
? <CaretRight size={12} />
: <CaretDown size={12} />
}
</button>
</span>
{isGroupCollapsed
? <CaretRight size={12} className="text-muted-foreground" />
: <CaretDown size={12} className="text-muted-foreground" />
}
</button>
{!isGroupCollapsed && group.entries.map((groupEntry) => renderItem(groupEntry))}
</div>
{!isGroupCollapsed && sortedEntries.map((groupEntry) => renderItem(groupEntry))}
</div>
)
})
)}
</div>
)
}, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem, typeEntryMap])
}, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem, typeEntryMap, sortPrefs, handleSortChange])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
@@ -426,6 +574,13 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
: 'Notes'}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && (
<SortDropdown
groupLabel="__list__"
current={listSort}
onChange={handleSortChange}
/>
)}
<button
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }}

View File

@@ -673,7 +673,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 60,
fileSize: 2048,
snippet: 'This paragraph has bold text, italic text, bold italic, strikethrough, and inline code. Here\'s a regular link and a wiki-link to Matteo Cellini.',
relationships: {
@@ -697,7 +697,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 3600,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 180,
fileSize: 1024,
snippet: 'Build a sustainable audience through high-quality weekly essays on engineering leadership, AI, and personal systems.',
relationships: {
@@ -726,7 +726,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 7200,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 150,
fileSize: 890,
snippet: 'Revenue stream from newsletter sponsorships. Matteo Cellini handles day-to-day operations.',
relationships: {
@@ -744,12 +744,12 @@ const MOCK_ENTRIES: VaultEntry[] = [
aliases: [],
belongsTo: ['[[responsibility/grow-newsletter]]'],
relatedTo: [],
status: 'Active',
status: 'Paused',
owner: 'Luca Rossi',
cadence: 'Weekly',
archived: false,
modifiedAt: Date.now() / 1000 - 86400,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 120,
fileSize: 512,
snippet: 'Monday: Pick topic, outline Tuesday: First draft Wednesday: Edit and polish Thursday: Schedule for Tuesday send',
relationships: {
@@ -767,12 +767,12 @@ const MOCK_ENTRIES: VaultEntry[] = [
aliases: [],
belongsTo: ['[[responsibility/manage-sponsorships]]'],
relatedTo: [],
status: 'Active',
status: 'Done',
owner: 'Matteo Cellini',
cadence: 'Weekly',
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 2,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 100,
fileSize: 640,
snippet: 'Review pipeline in CRM Follow up with pending proposals Schedule confirmed sponsors Send performance reports to completed sponsors',
relationships: {
@@ -790,12 +790,12 @@ const MOCK_ENTRIES: VaultEntry[] = [
aliases: ['Trading Screener'],
belongsTo: [],
relatedTo: ['[[topic/trading]]', '[[topic/algorithmic-trading]]'],
status: 'Active',
status: 'Paused',
owner: 'Luca Rossi',
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 45,
fileSize: 3200,
snippet: 'Stocks that wick below the 200-day EMA and close above it show a statistically significant bounce in the following 5-10 days.',
relationships: {
@@ -819,7 +819,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 3600 * 5,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 30,
fileSize: 847,
snippet: 'Lookalike audiences from newsletter subscribers convert 3x better than interest-based targeting Video ads outperform static images by 40% on engagement',
relationships: {
@@ -843,7 +843,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 20,
fileSize: 560,
snippet: 'Under budget on ads due to improved targeting efficiency Consider reallocating savings to content production',
relationships: {
@@ -866,7 +866,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 7,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 200,
fileSize: 320,
snippet: 'Sponsorship manager — handles all sponsor relationships, proposals, and reporting.',
relationships: {
@@ -888,7 +888,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 3600 * 2,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 7,
fileSize: 1200,
snippet: 'Agreed on four-panel layout inspired by Bear Notes CodeMirror 6 for the editor — live preview is critical MVP by end of Q1.',
relationships: {
@@ -911,7 +911,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 30,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 256,
snippet: 'A broad topic covering everything from frontend to systems programming.',
relationships: {
@@ -934,7 +934,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 14,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 300,
fileSize: 180,
snippet: 'Technical analysis (EMA, RSI, volume patterns) Algorithmic screening and alerts Risk management and position sizing',
relationships: {
@@ -957,7 +957,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 3,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 14,
fileSize: 4200,
snippet: 'Good writing is lean and confident. Every sentence should serve a purpose.',
relationships: {
@@ -980,7 +980,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 7,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 30,
fileSize: 3800,
snippet: 'The transition from IC to manager is the hardest career shift in engineering.',
relationships: {
@@ -1004,7 +1004,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 10,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 21,
fileSize: 5100,
snippet: 'AI agents are autonomous systems that can plan, execute, and adapt to achieve goals.',
relationships: {
@@ -1028,7 +1028,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 320,
snippet: 'A time-bound initiative that advances a Responsibility. Projects have a clear start, end, and deliverables.',
relationships: {},
@@ -1048,7 +1048,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 280,
snippet: 'An ongoing area of ownership — something you\'re accountable for indefinitely.',
relationships: {},
@@ -1068,7 +1068,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 310,
snippet: 'A recurring process tied to a Responsibility. Procedures have a cadence and describe how to do something.',
relationships: {},
@@ -1088,7 +1088,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 290,
snippet: 'A hypothesis-driven investigation with a clear test and measurable outcome.',
relationships: {},
@@ -1108,7 +1108,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 200,
snippet: 'A person you interact with — team members, collaborators, contacts.',
relationships: {},
@@ -1128,7 +1128,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 180,
snippet: 'A point-in-time occurrence — meetings, launches, milestones.',
relationships: {},
@@ -1148,7 +1148,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 170,
snippet: 'A subject area for categorization. Topics group related notes, projects, and resources by theme.',
relationships: {},
@@ -1168,7 +1168,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 200,
snippet: 'A published piece of writing — newsletter essays, blog posts, articles.',
relationships: {},
@@ -1188,7 +1188,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 190,
snippet: 'A general-purpose document — research notes, meeting notes, strategy docs.',
relationships: {},
@@ -1209,7 +1209,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 30,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 250,
snippet: 'A recipe for cooking or baking. Recipes have ingredients, steps, and serving info.',
relationships: {},
@@ -1229,7 +1229,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 30,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 365,
fileSize: 220,
snippet: 'A book you\'re reading or have read. Track reading progress, notes, and key takeaways.',
relationships: {},
@@ -1250,7 +1250,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 5,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 10,
fileSize: 420,
snippet: 'Classic Roman pasta dish with eggs, pecorino, guanciale, and black pepper.',
relationships: {
@@ -1272,7 +1272,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
cadence: null,
archived: false,
modifiedAt: Date.now() / 1000 - 86400 * 14,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 60,
fileSize: 380,
snippet: 'Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions.',
relationships: {
@@ -1297,7 +1297,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
modifiedAt: Date.now() / 1000 - 86400 * 120,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 200,
fileSize: 680,
snippet: 'Completed redesign of the company website. Migrated from WordPress to Next.js with improved performance and SEO.',
relationships: {
@@ -1320,7 +1320,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: null,
createdAt: Date.now() / 1000 - 86400 * 150,
fileSize: 520,
snippet: 'Publishing 3 Twitter threads per week instead of 1 will increase newsletter signups by 50%. Result: only 12% increase.',
relationships: {