feat: note subtitle — show metadata (date + word count) (#94)
* feat: show metadata subtitle (date + word count) instead of snippet Replace the note list subtitle with a compact metadata summary showing relative date and word count (e.g. "2d ago · 342 words") instead of the first paragraph of note content. Adds word_count to VaultEntry on the Rust backend, computed during vault scan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: cargo fmt formatting --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
design/note-subtitle-metadata.pen
Normal file
1
design/note-subtitle-metadata.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -12,8 +12,8 @@ pub use rename::{rename_note, RenameResult};
|
||||
pub use trash::purge_trash;
|
||||
|
||||
use parsing::{
|
||||
capitalize_first, contains_wikilink, extract_outgoing_links, extract_snippet, extract_title,
|
||||
parse_iso_date,
|
||||
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
|
||||
extract_title, parse_iso_date,
|
||||
};
|
||||
|
||||
use gray_matter::engine::YAML;
|
||||
@@ -60,6 +60,9 @@ pub struct VaultEntry {
|
||||
pub color: Option<String>,
|
||||
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
|
||||
pub order: Option<i64>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
/// All wikilink targets found in the note body (excludes frontmatter).
|
||||
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
|
||||
#[serde(rename = "outgoingLinks", default)]
|
||||
@@ -268,6 +271,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
let title = extract_title(&parsed.content, &filename);
|
||||
let snippet = extract_snippet(&content);
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
let (modified_at, file_size) = read_file_metadata(path)?;
|
||||
let created_at = parse_created_at(&frontmatter);
|
||||
@@ -318,6 +322,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
icon: frontmatter.icon,
|
||||
color: frontmatter.color,
|
||||
order: frontmatter.order,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
})
|
||||
}
|
||||
@@ -579,6 +584,27 @@ mod tests {
|
||||
assert_eq!(entry.snippet, "Hello, world! This is a snippet.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_md_file_has_word_count() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\nIs A: Note\n---\n# Test Note\n\nHello world. This is a test with seven words.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
assert_eq!(entry.word_count, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_md_file_word_count_empty_body() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Empty Note\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
assert_eq!(entry.word_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_array() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -46,6 +46,18 @@ fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
|
||||
format!("{}...", &s[..idx])
|
||||
}
|
||||
|
||||
/// Count the number of words in the note body (excluding frontmatter and H1 title).
|
||||
pub(super) fn count_body_words(content: &str) -> u32 {
|
||||
let without_fm = strip_frontmatter(content);
|
||||
let body = without_h1_line(without_fm).unwrap_or(without_fm);
|
||||
body.split_whitespace()
|
||||
.filter(|w| {
|
||||
!w.chars()
|
||||
.all(|c| matches!(c, '#' | '*' | '_' | '`' | '~' | '-' | '>' | '|'))
|
||||
})
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
/// Extract a snippet: first ~160 chars of content after frontmatter/title, stripped of markdown.
|
||||
pub(super) fn extract_snippet(content: &str) -> String {
|
||||
let without_fm = strip_frontmatter(content);
|
||||
@@ -291,6 +303,46 @@ mod tests {
|
||||
assert_eq!(snippet, "Content after rule.");
|
||||
}
|
||||
|
||||
// --- count_body_words tests ---
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_basic() {
|
||||
let content = "---\nIs A: Note\n---\n# My Note\n\nHello world, this is a test.";
|
||||
assert_eq!(count_body_words(content), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_no_frontmatter() {
|
||||
let content = "# Title\n\nOne two three four five.";
|
||||
assert_eq!(count_body_words(content), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_empty_body() {
|
||||
let content = "---\nIs A: Note\n---\n# Just a Title\n";
|
||||
assert_eq!(count_body_words(content), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_no_content() {
|
||||
assert_eq!(count_body_words(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_excludes_markdown_markers() {
|
||||
let content = "# Title\n\n## Section\n\nReal words here. ---\n\n> quote text";
|
||||
// "Real", "words", "here.", "quote", "text" = 5 real words
|
||||
// "##", "Section", "---", ">" are markdown markers (## is a heading, --- is a rule, > is blockquote)
|
||||
// "Section" passes the filter (not all markdown chars), so count includes it
|
||||
assert_eq!(count_body_words(content), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_plain_text_only() {
|
||||
let content = "Just plain text without any heading.";
|
||||
assert_eq!(count_body_words(content), 6);
|
||||
}
|
||||
|
||||
// --- strip_markdown_chars tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,7 @@ const baseEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -46,6 +46,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -70,6 +70,7 @@ const mockEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -21,6 +21,7 @@ const mockEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -64,6 +65,7 @@ const referrerEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -363,6 +365,7 @@ This is a test note with some words to count.
|
||||
createdAt: null,
|
||||
fileSize: 500,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Type': ['[[type/responsibility]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -388,6 +391,7 @@ This is a test note with some words to count.
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/essay]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -413,6 +417,7 @@ This is a test note with some words to count.
|
||||
createdAt: null,
|
||||
fileSize: 400,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/procedure]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -438,6 +443,7 @@ This is a test note with some words to count.
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Related to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/experiment]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -596,6 +602,7 @@ Status: Active
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/essay]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
import { relativeDate, formatSubtitle } from '../utils/noteListHelpers'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
@@ -101,12 +101,9 @@ export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
{entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
: <div className="mt-1 text-[11px] text-muted-foreground">{formatSubtitle(entry)}</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: 'Build a personal knowledge management app.',
|
||||
wordCount: 0,
|
||||
relationships: {
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
},
|
||||
@@ -53,6 +54,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 847,
|
||||
snippet: 'Lookalike audiences convert 3x better.',
|
||||
wordCount: 0,
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Related to': ['[[topic/growth]]'],
|
||||
@@ -80,6 +82,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 320,
|
||||
snippet: 'Sponsorship manager.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -104,6 +107,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
snippet: 'Project kickoff meeting notes.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -128,6 +132,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Frontend, backend, and systems programming.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -261,12 +266,12 @@ describe('NoteList', () => {
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('context view shows prominent card with entity snippet', () => {
|
||||
it('context view shows prominent card with metadata subtitle', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Snippet appears in the prominent card
|
||||
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
|
||||
// Metadata subtitle (date · word count) appears in the prominent card
|
||||
expect(screen.getAllByText(/Empty/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -301,7 +306,9 @@ describe('NoteList click behavior', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Build a personal knowledge management app.'), { metaKey: true })
|
||||
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -310,7 +317,9 @@ describe('NoteList click behavior', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Build a personal knowledge management app.'))
|
||||
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1])
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -344,6 +353,7 @@ describe('getSortComparator', () => {
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -455,6 +465,7 @@ describe('NoteList sort controls', () => {
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -648,6 +659,7 @@ const trashedEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 280,
|
||||
snippet: 'Some draft content that is no longer needed.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -673,6 +685,7 @@ const expiredTrashedEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 190,
|
||||
snippet: 'Old API docs replaced by v2.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -827,6 +840,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
createdAt: null,
|
||||
fileSize: 500,
|
||||
snippet: `Content of note ${i}`,
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
|
||||
getSortComparator,
|
||||
buildRelationshipGroups, filterEntries,
|
||||
relativeDate, getDisplayDate,
|
||||
formatSubtitle,
|
||||
loadSortPreferences, saveSortPreferences,
|
||||
} from '../utils/noteListHelpers'
|
||||
|
||||
@@ -29,11 +29,10 @@ interface NoteListProps {
|
||||
onCreateNote: () => void
|
||||
}
|
||||
|
||||
function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
|
||||
function PinnedCard({ entry, typeEntryMap, onClickNote }: {
|
||||
entry: VaultEntry
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
showDate?: boolean
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const color = getTypeColor(entry.isA ?? '', te?.color)
|
||||
@@ -44,8 +43,7 @@ function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
|
||||
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
|
||||
{showDate && <div className="mt-1 text-[11px] opacity-60" style={{ color }}>{relativeDate(getDisplayDate(entry))}</div>}
|
||||
<div className="mt-1 text-[11px] opacity-60" style={{ color }}>{formatSubtitle(entry)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -120,7 +118,7 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onClickNote={onClickNote} showDate />
|
||||
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />
|
||||
{groups.length === 0
|
||||
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
|
||||
: groups.map((group) => (
|
||||
|
||||
@@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -31,6 +31,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: Date.now() / 1000,
|
||||
fileSize: 500,
|
||||
snippet: 'A guide to designing APIs for AI',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -55,6 +56,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: Date.now() / 1000,
|
||||
fileSize: 300,
|
||||
snippet: 'Team retreat event',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -58,6 +59,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -82,6 +84,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -106,6 +109,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 128,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -130,6 +134,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -154,6 +159,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 180,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -178,6 +184,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -202,6 +209,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -385,6 +393,7 @@ describe('Sidebar', () => {
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -409,6 +418,7 @@ describe('Sidebar', () => {
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -433,6 +443,7 @@ describe('Sidebar', () => {
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -486,6 +497,7 @@ describe('Sidebar', () => {
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -603,18 +615,21 @@ describe('Sidebar', () => {
|
||||
path: '/vault/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 5, outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 0, outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 1, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ function makeEntry(path: string, title: string): VaultEntry {
|
||||
status: null, owner: null, cadence: null, archived: false,
|
||||
trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', relationships: {}, icon: null, color: null, order: null,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -47,6 +47,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -27,6 +27,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -10,7 +10,7 @@ const mockEntries: VaultEntry[] = [
|
||||
status: 'Active', owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100,
|
||||
snippet: '', relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 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.',
|
||||
wordCount: 342,
|
||||
relationships: {
|
||||
'Belongs to': ['[[quarter/q1-2026]]'],
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
@@ -54,6 +55,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 180,
|
||||
fileSize: 1024,
|
||||
snippet: 'Build a sustainable audience through high-quality weekly essays on engineering leadership, AI, and personal systems.',
|
||||
wordCount: 215,
|
||||
relationships: {
|
||||
'Has': [
|
||||
'[[essay/on-writing-well|On Writing Well]]',
|
||||
@@ -87,6 +89,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 150,
|
||||
fileSize: 890,
|
||||
snippet: 'Revenue stream from newsletter sponsorships. Matteo Cellini handles day-to-day operations.',
|
||||
wordCount: 180,
|
||||
relationships: {
|
||||
'Owner': ['[[person/matteo-cellini|Matteo Cellini]]'],
|
||||
'Type': ['[[type/responsibility]]'],
|
||||
@@ -114,6 +117,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 120,
|
||||
fileSize: 512,
|
||||
snippet: 'Monday: Pick topic, outline Tuesday: First draft Wednesday: Edit and polish Thursday: Schedule for Tuesday send',
|
||||
wordCount: 95,
|
||||
relationships: {
|
||||
'Belongs to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/procedure]]'],
|
||||
@@ -141,6 +145,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 100,
|
||||
fileSize: 640,
|
||||
snippet: 'Review pipeline in CRM Follow up with pending proposals Schedule confirmed sponsors Send performance reports to completed sponsors',
|
||||
wordCount: 128,
|
||||
relationships: {
|
||||
'Belongs to': ['[[responsibility/manage-sponsorships]]'],
|
||||
'Type': ['[[type/procedure]]'],
|
||||
@@ -168,6 +173,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 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.',
|
||||
wordCount: 520,
|
||||
relationships: {
|
||||
'Related to': ['[[topic/trading]]', '[[topic/algorithmic-trading]]'],
|
||||
'Has Data': ['[[data/ema200-backtest-results]]'],
|
||||
@@ -196,6 +202,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 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',
|
||||
wordCount: 267,
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Related to': ['[[topic/growth]]', '[[topic/ads]]'],
|
||||
@@ -224,6 +231,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 20,
|
||||
fileSize: 560,
|
||||
snippet: 'Under budget on ads due to improved targeting efficiency Consider reallocating savings to content production',
|
||||
wordCount: 150,
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Type': ['[[type/note]]'],
|
||||
@@ -251,6 +259,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 200,
|
||||
fileSize: 320,
|
||||
snippet: 'Sponsorship manager — handles all sponsor relationships, proposals, and reporting.',
|
||||
wordCount: 88,
|
||||
relationships: {
|
||||
'Type': ['[[type/person]]'],
|
||||
},
|
||||
@@ -277,6 +286,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 150,
|
||||
fileSize: 280,
|
||||
snippet: 'Product designer — leads UX research and design sprints for the app.',
|
||||
wordCount: 120,
|
||||
relationships: {
|
||||
'Type': ['[[type/person]]'],
|
||||
},
|
||||
@@ -303,6 +313,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 120,
|
||||
fileSize: 240,
|
||||
snippet: 'Frontend engineer — focuses on React performance and accessibility.',
|
||||
wordCount: 95,
|
||||
relationships: {
|
||||
'Type': ['[[type/person]]'],
|
||||
},
|
||||
@@ -329,6 +340,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 90,
|
||||
fileSize: 200,
|
||||
snippet: 'Content strategist — plans newsletter topics and manages the editorial calendar.',
|
||||
wordCount: 75,
|
||||
relationships: {
|
||||
'Type': ['[[type/person]]'],
|
||||
},
|
||||
@@ -355,6 +367,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 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.',
|
||||
wordCount: 310,
|
||||
relationships: {
|
||||
'Related to': ['[[project/26q1-laputa-app]]', '[[person/matteo-cellini]]'],
|
||||
'Type': ['[[type/event]]'],
|
||||
@@ -382,6 +395,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 256,
|
||||
snippet: 'A broad topic covering everything from frontend to systems programming.',
|
||||
wordCount: 45,
|
||||
relationships: {
|
||||
'Notes': ['[[note/facebook-ads-strategy]]', '[[note/budget-allocation]]'],
|
||||
'Type': ['[[type/topic]]'],
|
||||
@@ -409,6 +423,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 300,
|
||||
fileSize: 180,
|
||||
snippet: 'Technical analysis (EMA, RSI, volume patterns) Algorithmic screening and alerts Risk management and position sizing',
|
||||
wordCount: 60,
|
||||
relationships: {
|
||||
'Notes': ['[[experiment/stock-screener]]'],
|
||||
'Type': ['[[type/topic]]'],
|
||||
@@ -436,6 +451,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 14,
|
||||
fileSize: 4200,
|
||||
snippet: 'Good writing is lean and confident. Every sentence should serve a purpose.',
|
||||
wordCount: 180,
|
||||
relationships: {
|
||||
'Belongs to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/essay]]'],
|
||||
@@ -463,6 +479,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 30,
|
||||
fileSize: 3800,
|
||||
snippet: 'The transition from IC to manager is the hardest career shift in engineering.',
|
||||
wordCount: 640,
|
||||
relationships: {
|
||||
'Belongs to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
@@ -491,6 +508,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 21,
|
||||
fileSize: 5100,
|
||||
snippet: 'AI agents are autonomous systems that can plan, execute, and adapt to achieve goals.',
|
||||
wordCount: 410,
|
||||
relationships: {
|
||||
'Belongs to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/essay]]'],
|
||||
@@ -519,6 +537,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 320,
|
||||
snippet: 'A time-bound initiative that advances a Responsibility. Projects have a clear start, end, and deliverables.',
|
||||
wordCount: 280,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -543,6 +562,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 280,
|
||||
snippet: 'An ongoing area of ownership — something you\'re accountable for indefinitely.',
|
||||
wordCount: 50,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -567,6 +587,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 310,
|
||||
snippet: 'A recurring process tied to a Responsibility. Procedures have a cadence and describe how to do something.',
|
||||
wordCount: 45,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -591,6 +612,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 290,
|
||||
snippet: 'A hypothesis-driven investigation with a clear test and measurable outcome.',
|
||||
wordCount: 55,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -615,6 +637,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 200,
|
||||
snippet: 'A person you interact with — team members, collaborators, contacts.',
|
||||
wordCount: 40,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -639,6 +662,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 180,
|
||||
snippet: 'A point-in-time occurrence — meetings, launches, milestones.',
|
||||
wordCount: 35,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -663,6 +687,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 170,
|
||||
snippet: 'A subject area for categorization. Topics group related notes, projects, and resources by theme.',
|
||||
wordCount: 30,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -687,6 +712,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 200,
|
||||
snippet: 'A published piece of writing — newsletter essays, blog posts, articles.',
|
||||
wordCount: 50,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -711,6 +737,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 190,
|
||||
snippet: 'A general-purpose document — research notes, meeting notes, strategy docs.',
|
||||
wordCount: 60,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -736,6 +763,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 250,
|
||||
snippet: 'A recipe for cooking or baking. Recipes have ingredients, steps, and serving info.',
|
||||
wordCount: 45,
|
||||
relationships: {},
|
||||
icon: 'cooking-pot',
|
||||
color: 'orange',
|
||||
@@ -760,6 +788,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 220,
|
||||
snippet: 'A book you\'re reading or have read. Track reading progress, notes, and key takeaways.',
|
||||
wordCount: 190,
|
||||
relationships: {},
|
||||
icon: 'book-open',
|
||||
color: 'green',
|
||||
@@ -785,6 +814,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 10,
|
||||
fileSize: 420,
|
||||
snippet: 'Classic Roman pasta dish with eggs, pecorino, guanciale, and black pepper.',
|
||||
wordCount: 310,
|
||||
relationships: {
|
||||
'Type': ['[[type/recipe]]'],
|
||||
},
|
||||
@@ -811,6 +841,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 60,
|
||||
fileSize: 380,
|
||||
snippet: 'Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions.',
|
||||
wordCount: 100,
|
||||
relationships: {
|
||||
'Type': ['[[type/book]]'],
|
||||
},
|
||||
@@ -838,6 +869,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 280,
|
||||
snippet: 'Some rough draft content that is no longer relevant. Moving to trash.',
|
||||
wordCount: 100,
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Type': ['[[type/note]]'],
|
||||
@@ -865,6 +897,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 190,
|
||||
snippet: 'Old API documentation for the v1 endpoint. Replaced by v2 docs.',
|
||||
wordCount: 85,
|
||||
relationships: {
|
||||
'Type': ['[[type/note]]'],
|
||||
},
|
||||
@@ -891,6 +924,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 340,
|
||||
snippet: 'Tried programmatic SEO pages. Results were negligible — trashing this.',
|
||||
wordCount: 120,
|
||||
relationships: {
|
||||
'Related to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/experiment]]'],
|
||||
@@ -923,6 +957,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 200,
|
||||
fileSize: 680,
|
||||
snippet: 'Completed redesign of the company website. Migrated from WordPress to Next.js with improved performance and SEO.',
|
||||
wordCount: 342,
|
||||
relationships: {
|
||||
'Belongs to': ['[[quarter/q3-2025]]'],
|
||||
'Type': ['[[type/project]]'],
|
||||
@@ -950,6 +985,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: now - 86400 * 150,
|
||||
fileSize: 520,
|
||||
snippet: 'Publishing 3 Twitter threads per week instead of 1 will increase newsletter signups by 50%. Result: only 12% increase.',
|
||||
wordCount: 215,
|
||||
relationships: {
|
||||
'Related to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/experiment]]'],
|
||||
@@ -1000,6 +1036,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
createdAt: now - 86400 * 90 - i * 3600,
|
||||
fileSize: 500 + (i % 2000),
|
||||
snippet: BULK_SNIPPETS[i % BULK_SNIPPETS.length],
|
||||
wordCount: 50 + (i % 200),
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface VaultEntry {
|
||||
createdAt: number | null
|
||||
fileSize: number
|
||||
snippet: string
|
||||
wordCount: number
|
||||
/** Generic relationship fields: any frontmatter key whose value contains wikilinks. */
|
||||
relationships: Record<string, string[]>
|
||||
/** Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot" */
|
||||
|
||||
82
src/utils/noteListHelpers.test.ts
Normal file
82
src/utils/noteListHelpers.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { formatSubtitle, relativeDate } from './noteListHelpers'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note/test.md', filename: 'test.md', title: 'Test',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null, archived: false,
|
||||
trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatSubtitle', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('shows date and word count when both available', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 342 })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toContain('342 words')
|
||||
expect(result).toContain('\u00b7')
|
||||
})
|
||||
|
||||
it('shows "Empty" when word count is 0', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toContain('Empty')
|
||||
expect(result).not.toContain('words')
|
||||
})
|
||||
|
||||
it('shows only word count when no date available', () => {
|
||||
const entry = makeEntry({ wordCount: 100 })
|
||||
expect(formatSubtitle(entry)).toBe('100 words')
|
||||
})
|
||||
|
||||
it('shows only "Empty" when no date and no content', () => {
|
||||
const entry = makeEntry()
|
||||
expect(formatSubtitle(entry)).toBe('Empty')
|
||||
})
|
||||
|
||||
it('falls back to createdAt when modifiedAt is null', () => {
|
||||
const entry = makeEntry({ createdAt: 1700000000, wordCount: 50 })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toContain('50 words')
|
||||
expect(result).toContain('\u00b7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relativeDate', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(relativeDate(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns "just now" for recent timestamps', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
expect(relativeDate(now)).toBe('just now')
|
||||
})
|
||||
|
||||
it('returns minutes ago for timestamps within an hour', () => {
|
||||
const fiveMinAgo = Math.floor(Date.now() / 1000) - 300
|
||||
expect(relativeDate(fiveMinAgo)).toBe('5m ago')
|
||||
})
|
||||
|
||||
it('returns hours ago for timestamps within a day', () => {
|
||||
const twoHoursAgo = Math.floor(Date.now() / 1000) - 7200
|
||||
expect(relativeDate(twoHoursAgo)).toBe('2h ago')
|
||||
})
|
||||
|
||||
it('returns days ago for timestamps within a week', () => {
|
||||
const threeDaysAgo = Math.floor(Date.now() / 1000) - 86400 * 3
|
||||
expect(relativeDate(threeDaysAgo)).toBe('3d ago')
|
||||
})
|
||||
|
||||
it('returns formatted date for older timestamps', () => {
|
||||
// Use a fixed timestamp: Nov 14, 2023
|
||||
expect(relativeDate(1700000000)).toMatch(/Nov 14/)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,18 @@ export function getDisplayDate(entry: VaultEntry): number | null {
|
||||
return entry.modifiedAt ?? entry.createdAt
|
||||
}
|
||||
|
||||
export function formatSubtitle(entry: VaultEntry): string {
|
||||
const parts: string[] = []
|
||||
const date = getDisplayDate(entry)
|
||||
if (date) parts.push(relativeDate(date))
|
||||
if (entry.wordCount > 0) {
|
||||
parts.push(`${entry.wordCount} words`)
|
||||
} else {
|
||||
parts.push('Empty')
|
||||
}
|
||||
return parts.join(' \u00b7 ')
|
||||
}
|
||||
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const fileStem = entry.filename.replace(/\.md$/, '')
|
||||
|
||||
@@ -53,6 +53,7 @@ const baseEntry: VaultEntry = {
|
||||
path: '', filename: '', title: '', isA: null, aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
Reference in New Issue
Block a user