feat: sort notes by property in relationship subsections
This commit is contained in:
5987
design/sort-notes.pen
Normal file
5987
design/sort-notes.pen
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('') }}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user