From 4664f3360e4cd052d6bacf627db1ce8b71a545f5 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Thu, 26 Feb 2026 20:50:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20note=20subtitle=20=E2=80=94=20show=20me?= =?UTF-8?q?tadata=20(date=20+=20word=20count)=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix: cargo fmt formatting --------- Co-authored-by: Claude Opus 4.6 --- design/note-subtitle-metadata.pen | 1 + src-tauri/src/vault/mod.rs | 30 ++++++- src-tauri/src/vault/parsing.rs | 52 ++++++++++++ src/components/BreadcrumbBar.test.tsx | 1 + .../DynamicPropertiesPanel.test.tsx | 1 + src/components/Editor.test.tsx | 1 + src/components/Inspector.test.tsx | 7 ++ src/components/InspectorPanels.test.tsx | 1 + src/components/NoteAutocomplete.test.tsx | 1 + src/components/NoteItem.tsx | 7 +- src/components/NoteList.test.tsx | 24 ++++-- src/components/NoteList.tsx | 10 +-- src/components/QuickOpenPalette.test.tsx | 1 + src/components/SearchPanel.test.tsx | 2 + src/components/Sidebar.test.tsx | 15 ++++ src/components/TabBar.test.tsx | 2 +- src/hooks/useCommandRegistry.test.ts | 1 + src/hooks/useEntryActions.test.ts | 1 + src/hooks/useKeyboardNavigation.test.ts | 1 + src/hooks/useNoteActions.test.ts | 1 + src/hooks/useNoteActions.ts | 2 +- src/hooks/useNoteSearch.test.ts | 1 + src/hooks/useTabManagement.test.ts | 1 + src/hooks/useVaultLoader.test.ts | 2 +- src/mock-tauri/mock-entries.ts | 37 +++++++++ src/types.ts | 1 + src/utils/noteListHelpers.test.ts | 82 +++++++++++++++++++ src/utils/noteListHelpers.ts | 12 +++ src/utils/typeColors.test.ts | 1 + src/utils/wikilinkColors.test.ts | 1 + 30 files changed, 279 insertions(+), 21 deletions(-) create mode 100644 design/note-subtitle-metadata.pen create mode 100644 src/utils/noteListHelpers.test.ts diff --git a/design/note-subtitle-metadata.pen b/design/note-subtitle-metadata.pen new file mode 100644 index 00000000..747b5ab3 --- /dev/null +++ b/design/note-subtitle-metadata.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 1e422c93..435655b9 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -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, /// Display order for Type entries in sidebar (lower = higher). None = use default order. pub order: Option, + /// 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 { 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 { 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(); diff --git a/src-tauri/src/vault/parsing.rs b/src-tauri/src/vault/parsing.rs index b9e6237c..97dd4ae7 100644 --- a/src-tauri/src/vault/parsing.rs +++ b/src-tauri/src/vault/parsing.rs @@ -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] diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 4ae4e65c..afe0113a 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -21,6 +21,7 @@ const baseEntry: VaultEntry = { createdAt: null, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index b77e8261..344f41b5 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -46,6 +46,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index e756a9c8..3ed863b8 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -70,6 +70,7 @@ const mockEntry: VaultEntry = { createdAt: null, fileSize: 1024, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index edf7e1da..5910b9e5 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -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, diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index 9744c4c4..a9725342 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -25,6 +25,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/components/NoteAutocomplete.test.tsx b/src/components/NoteAutocomplete.test.tsx index 184e4662..b29285ec 100644 --- a/src/components/NoteAutocomplete.test.tsx +++ b/src/components/NoteAutocomplete.test.tsx @@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index ca1be310..fdcfdcf6 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -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>> = { Project: Wrench, @@ -101,12 +101,9 @@ export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap )} -
- {entry.snippet} -
{entry.trashed && entry.trashedAt ? - :
{relativeDate(getDisplayDate(entry))}
+ :
{formatSubtitle(entry)}
} ) diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 8ab3a267..22664f11 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -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( ) - // 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( ) - 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( ) - 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, diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index f5282d96..8b2e6321 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -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 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 */}
{entry.title}
-
{entry.snippet}
- {showDate &&
{relativeDate(getDisplayDate(entry))}
} +
{formatSubtitle(entry)}
) } @@ -120,7 +118,7 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl }) { return (
- + {groups.length === 0 ? : groups.map((group) => ( diff --git a/src/components/QuickOpenPalette.test.tsx b/src/components/QuickOpenPalette.test.tsx index d00dceee..39260005 100644 --- a/src/components/QuickOpenPalette.test.tsx +++ b/src/components/QuickOpenPalette.test.tsx @@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx index 81ed1d17..85b4ba0d 100644 --- a/src/components/SearchPanel.test.tsx +++ b/src/components/SearchPanel.test.tsx @@ -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, diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 952b317c..3b3bdfe0 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -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: [], }, ] diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx index 07b08b9c..b6086a68 100644 --- a/src/components/TabBar.test.tsx +++ b/src/components/TabBar.test.tsx @@ -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: [], } } diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index c495868c..5dd032aa 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts index b49c8ec7..3ae27290 100644 --- a/src/hooks/useEntryActions.test.ts +++ b/src/hooks/useEntryActions.test.ts @@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/hooks/useKeyboardNavigation.test.ts b/src/hooks/useKeyboardNavigation.test.ts index 16ae9f14..01917aef 100644 --- a/src/hooks/useKeyboardNavigation.test.ts +++ b/src/hooks/useKeyboardNavigation.test.ts @@ -25,6 +25,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 9785a6dd..adb23190 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -47,6 +47,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index adddea12..0c401915 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -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: [], } } diff --git a/src/hooks/useNoteSearch.test.ts b/src/hooks/useNoteSearch.test.ts index d1b1bb06..a45a43b0 100644 --- a/src/hooks/useNoteSearch.test.ts +++ b/src/hooks/useNoteSearch.test.ts @@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index f352358a..ae7b52b7 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -27,6 +27,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ createdAt: 1700000000, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null, diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 697236c4..4a291c68 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -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: [], }, ] diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index 49a45dd4..2ebeebf4 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -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, diff --git a/src/types.ts b/src/types.ts index 2fc38734..94484855 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 /** Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot" */ diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts new file mode 100644 index 00000000..0edf7ec4 --- /dev/null +++ b/src/utils/noteListHelpers.test.ts @@ -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 { + 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/) + }) +}) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 8bc0068e..ba46ae90 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -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$/, '') diff --git a/src/utils/typeColors.test.ts b/src/utils/typeColors.test.ts index 4a7c7908..2eae4107 100644 --- a/src/utils/typeColors.test.ts +++ b/src/utils/typeColors.test.ts @@ -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: [], } diff --git a/src/utils/wikilinkColors.test.ts b/src/utils/wikilinkColors.test.ts index b7f836ab..9ca959e5 100644 --- a/src/utils/wikilinkColors.test.ts +++ b/src/utils/wikilinkColors.test.ts @@ -21,6 +21,7 @@ function makeEntry(overrides: Partial): VaultEntry { createdAt: null, fileSize: 100, snippet: '', + wordCount: 0, relationships: {}, icon: null, color: null,