From 80dd525898c4d355d237979b2b75bd81ef11c0e5 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Fri, 27 Feb 2026 15:27:11 +0100 Subject: [PATCH] refactor: extract SearchResultItem and SearchResultsList from SearchContent (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: search subtitle metadata layout with 2 frames Frame 1: Search result items with metadata subtitle line showing modified date, word count, and link count below the snippet. Frame 2: NoteList items with enhanced subtitle including link count. Co-Authored-By: Claude Opus 4.6 * feat: add metadata subtitle to search results and note list items - formatSubtitle now shows link count (e.g. "2h ago · 342 words · 5 links") - New formatSearchSubtitle shows full metadata in search results: modified date, created date, word count, and link count - SearchPanel renders metadata line under each search result snippet - Mock entries updated with realistic outgoingLinks data - 11 new tests covering formatSearchSubtitle and search result metadata Co-Authored-By: Claude Opus 4.6 * refactor: extract SearchResultItem and SearchResultsList from SearchContent Reduces cyclomatic complexity from 24 to manageable levels by splitting the monolithic SearchContent into three focused components. Code Health: 8.97 -> 9.23. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- design/search-subtitle-metadata.pen | 436 ++++++++++++++++++++++++++++ src/components/SearchPanel.test.tsx | 37 ++- src/components/SearchPanel.tsx | 177 +++++++---- src/mock-tauri/mock-entries.ts | 26 +- src/utils/noteListHelpers.test.ts | 76 ++++- src/utils/noteListHelpers.ts | 23 +- 6 files changed, 695 insertions(+), 80 deletions(-) create mode 100644 design/search-subtitle-metadata.pen diff --git a/design/search-subtitle-metadata.pen b/design/search-subtitle-metadata.pen new file mode 100644 index 00000000..fe4ea0c2 --- /dev/null +++ b/design/search-subtitle-metadata.pen @@ -0,0 +1,436 @@ +{"children":[ + { + "type": "frame", + "id": "f_search_meta", + "name": "Search Result — with metadata subtitle", + "x": 0, + "y": 0, + "width": 540, + "height": "fit_content", + "fill": "$--search-bg", + "layout": "vertical", + "padding": [24, 0], + "gap": 0, + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "text", + "id": "f1_heading", + "content": "Search Result Item — with metadata subtitle", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700", + "width": "fill_container", + "padding": [0, 16, 12, 16] + }, + { + "type": "text", + "id": "f1_desc", + "content": "Each search result now shows a metadata line below the snippet: modified date, word count, and link count. The type badge remains next to the title.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "lineHeight": 1.5, + "width": "fill_container", + "padding": [0, 16, 16, 16] + }, + { + "type": "frame", + "id": "f1_countbar", + "width": "fill_container", + "padding": [6, 16], + "stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "text", + "id": "f1_count", + "content": "12 results · 148ms", + "fill": "$--search-highlight", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "f1_result1", + "name": "Result 1 — Selected", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "fill": "$--accent", + "children": [ + { + "type": "frame", + "id": "f1_r1_titlerow", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "f1_r1_title", + "content": "How to Manage your Time", + "fill": "$--search-heading", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "f1_r1_badge", + "cornerRadius": 3, + "fill": "$--secondary", + "padding": [2, 6], + "children": [ + { + "type": "text", + "id": "f1_r1_badgeText", + "content": "Essay", + "fill": "$--search-text-muted", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "f1_r1_snippet", + "content": "...how people can improve their time management skills. They gathered results in this article...", + "fill": "$--search-snippet", + "fontFamily": "Inter", + "fontSize": 12, + "lineHeight": 1.4, + "textGrowth": "fixed-width", + "width": "fill_container" + }, + { + "type": "frame", + "id": "f1_r1_meta", + "name": "metadata line", + "width": "fill_container", + "gap": 0, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f1_r1_m1", + "content": "Modified 2h ago · 1,240 words · 7 links", + "fill": "$--search-text-dim", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "f1_result2", + "name": "Result 2 — Normal", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "top": 1 } }, + "children": [ + { + "type": "frame", + "id": "f1_r2_titlerow", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "f1_r2_title", + "content": "Weekly Review Process", + "fill": "$--search-heading", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "f1_r2_badge", + "cornerRadius": 3, + "fill": "$--secondary", + "padding": [2, 6], + "children": [ + { + "type": "text", + "id": "f1_r2_badgeText", + "content": "Procedure", + "fill": "$--search-text-muted", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "f1_r2_snippet", + "content": "Every Sunday morning, review the week's progress and plan the next week...", + "fill": "$--search-snippet", + "fontFamily": "Inter", + "fontSize": 12, + "lineHeight": 1.4, + "textGrowth": "fixed-width", + "width": "fill_container" + }, + { + "type": "frame", + "id": "f1_r2_meta", + "name": "metadata line", + "width": "fill_container", + "gap": 0, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f1_r2_m1", + "content": "Modified 3d ago · 520 words · 12 links", + "fill": "$--search-text-dim", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "f1_result3", + "name": "Result 3 — No links", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [10, 16], + "stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "top": 1 } }, + "children": [ + { + "type": "frame", + "id": "f1_r3_titlerow", + "width": "fill_container", + "alignItems": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "f1_r3_title", + "content": "Morning Routine", + "fill": "$--search-heading", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "f1_r3_snippet", + "content": "A simple checklist for starting the day with intention...", + "fill": "$--search-snippet", + "fontFamily": "Inter", + "fontSize": 12, + "lineHeight": 1.4, + "textGrowth": "fixed-width", + "width": "fill_container" + }, + { + "type": "frame", + "id": "f1_r3_meta", + "name": "metadata line", + "width": "fill_container", + "gap": 0, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f1_r3_m1", + "content": "Modified Jan 15 · 89 words", + "fill": "$--search-text-dim", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "f_notelist_meta", + "name": "NoteList Item — subtitle with link count", + "x": 600, + "y": 0, + "width": 340, + "height": "fit_content", + "fill": "$--card", + "layout": "vertical", + "padding": [24, 0], + "gap": 0, + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "text", + "id": "f2_heading", + "content": "NoteList Item — enhanced subtitle", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700", + "width": "fill_container", + "padding": [0, 16, 12, 16] + }, + { + "type": "text", + "id": "f2_desc", + "content": "Normal NoteList items add a link count after date and word count. The separator is a middle dot (·). When link count is 0, it's omitted.", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11, + "lineHeight": 1.5, + "width": "fill_container", + "padding": [0, 16, 16, 16] + }, + { + "type": "frame", + "id": "f2_item1", + "name": "NoteListItem — with links", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "f2_i1_titlerow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f2_i1_title", + "content": "Build Laputa App", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "f2_i1_sub", + "content": "2h ago · 342 words · 5 links", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "f2_item2", + "name": "NoteListItem — no links", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "f2_i2_titlerow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f2_i2_title", + "content": "Quick Meeting Notes", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "f2_i2_sub", + "content": "5d ago · 120 words", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "f2_item3", + "name": "NoteListItem — empty note", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "padding": [14, 16], + "stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } }, + "children": [ + { + "type": "frame", + "id": "f2_i3_titlerow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f2_i3_title", + "content": "New Untitled Note", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "f2_i3_sub", + "content": "just now · Empty", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 11 + } + ] + } + ] + } +],"variables":{ + "--search-bg": {"type":"color","value":[{"value":"#FFFFFF"},{"theme":{"Mode":"Dark"},"value":"#1E1E2E"}]}, + "--search-heading": {"type":"color","value":[{"value":"#37352F"},{"theme":{"Mode":"Dark"},"value":"#e0e0f0"}]}, + "--search-snippet": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#888899"}]}, + "--search-text-dim": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#666680"}]}, + "--search-text-muted": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#8888aa"}]}, + "--search-highlight": {"type":"color","value":[{"value":"#37352F"},{"theme":{"Mode":"Dark"},"value":"#555570"}]}, + "--search-border": {"type":"color","value":[{"value":"#E9E9E7"},{"theme":{"Mode":"Dark"},"value":"#4c4c6d"}]}, + "--foreground": {"type":"color","value":[{"value":"#1C1C1C"},{"theme":{"Mode":"Dark"},"value":"#F5F5F5"}]}, + "--muted-foreground": {"type":"color","value":[{"value":"#737373"},{"theme":{"Mode":"Dark"},"value":"#A3A3A3"}]}, + "--border": {"type":"color","value":[{"value":"#E5E5E5"},{"theme":{"Mode":"Dark"},"value":"#333333"}]}, + "--card": {"type":"color","value":[{"value":"#FFFFFF"},{"theme":{"Mode":"Dark"},"value":"#1C1C1C"}]}, + "--accent": {"type":"color","value":[{"value":"#F5F5F5"},{"theme":{"Mode":"Dark"},"value":"#262626"}]}, + "--secondary": {"type":"color","value":[{"value":"#F5F5F5"},{"theme":{"Mode":"Dark"},"value":"#262626"}]} +}} \ No newline at end of file diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx index 85b4ba0d..df666c16 100644 --- a/src/components/SearchPanel.test.tsx +++ b/src/components/SearchPanel.test.tsx @@ -12,6 +12,8 @@ vi.mock('../mock-tauri', () => ({ import { mockInvoke } from '../mock-tauri' const mockInvokeFn = vi.mocked(mockInvoke) +const NOW_TS = Date.now() / 1000 + const MOCK_ENTRIES: VaultEntry[] = [ { path: '/vault/essay/ai-apis.md', @@ -27,16 +29,16 @@ const MOCK_ENTRIES: VaultEntry[] = [ archived: false, trashed: false, trashedAt: null, - modifiedAt: Date.now() / 1000, - createdAt: Date.now() / 1000, + modifiedAt: NOW_TS - 3600, + createdAt: NOW_TS - 86400 * 30, fileSize: 500, snippet: 'A guide to designing APIs for AI', - wordCount: 0, + wordCount: 1240, relationships: {}, icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['topic/ai', 'topic/apis', 'person/luca'], }, { path: '/vault/event/retreat.md', @@ -52,8 +54,8 @@ const MOCK_ENTRIES: VaultEntry[] = [ archived: false, trashed: false, trashedAt: null, - modifiedAt: Date.now() / 1000, - createdAt: Date.now() / 1000, + modifiedAt: NOW_TS, + createdAt: NOW_TS, fileSize: 300, snippet: 'Team retreat event', wordCount: 0, @@ -371,6 +373,29 @@ describe('SearchPanel', () => { expect(screen.getByText('Keyword Only')).toBeInTheDocument() }) + it('shows metadata subtitle on search results', async () => { + mockInvokeFn.mockResolvedValue({ + results: [ + { title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'API content', score: 0.9, note_type: 'Essay' }, + ], + elapsed_ms: 50, + }) + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } }) + + await waitFor(() => { + const meta = screen.getByTestId('search-result-meta') + expect(meta).toBeInTheDocument() + expect(meta.textContent).toContain('words') + expect(meta.textContent).toContain('3 links') + expect(meta.textContent).toContain('Created') + }) + }) + it('cancels inflight searches when panel closes', async () => { const resolvers: ((v: unknown) => void)[] = [] mockInvokeFn.mockImplementation( diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index 28e05b6d..524b0cd2 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge' import type { SearchResult, VaultEntry } from '../types' import { useUnifiedSearch } from '../hooks/useUnifiedSearch' import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors' +import { formatSearchSubtitle } from '../utils/noteListHelpers' interface SearchPanelProps { open: boolean @@ -63,8 +64,8 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const entryLookup = useMemo(() => { - const map = new Map() - for (const e of entries) map.set(e.path, { isA: e.isA }) + const map = new Map() + for (const e of entries) map.set(e.path, e) return map }, [entries]) @@ -142,13 +143,92 @@ const SearchInput = forwardRef( }, ) +function SearchResultItem({ result, isSelected, entry, typeEntryMap, onSelect, onHover }: { + result: SearchResult + isSelected: boolean + entry: VaultEntry | undefined + typeEntryMap: Record + onSelect: () => void + onHover: () => void +}) { + const isA = entry?.isA ?? result.noteType + const noteType = isA && isA !== 'Note' ? isA : null + const typeColor = noteType ? getTypeColor(isA, typeEntryMap[isA ?? '']?.color) : undefined + const typeLightColor = noteType ? getTypeLightColor(isA, typeEntryMap[isA ?? '']?.color) : undefined + const subtitle = entry ? formatSearchSubtitle(entry) : null + + return ( +
+
+ {result.title} + {noteType && ( + + {noteType} + + )} +
+ {result.snippet && ( +

+ {result.snippet} +

+ )} + {subtitle && ( +

+ {subtitle} +

+ )} +
+ ) +} + +function SearchResultsList({ results, selectedIndex, entryLookup, typeEntryMap, elapsedMs, listRef, onSelect, onHover }: { + results: SearchResult[] + selectedIndex: number + entryLookup: Map + typeEntryMap: Record + elapsedMs: number | null + listRef: React.RefObject + onSelect: (result: SearchResult) => void + onHover: (index: number) => void +}) { + return ( + <> +
+ + {results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''} + +
+
+ {results.map((result, i) => ( + onSelect(result)} + onHover={() => onHover(i)} + /> + ))} +
+ + ) +} + interface SearchContentProps { query: string results: SearchResult[] selectedIndex: number loading: boolean elapsedMs: number | null - entryLookup: Map + entryLookup: Map typeEntryMap: Record listRef: React.RefObject onSelect: (result: SearchResult) => void @@ -158,71 +238,50 @@ interface SearchContentProps { function SearchContent({ query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover, }: SearchContentProps) { - return ( -
- {!query.trim() && ( + const hasQuery = query.trim().length > 0 + const hasResults = results.length > 0 + + if (!hasQuery) { + return ( +

Search across all note contents

-

- Enter to open · Esc to close -

+

Enter to open · Esc to close

- )} +
+ ) + } - {query.trim() && results.length === 0 && loading && ( -
- Searching... -
- )} + if (!hasResults && loading) { + return ( +
+
Searching...
+
+ ) + } - {query.trim() && results.length === 0 && !loading && ( + if (!hasResults) { + return ( +

No results found

- )} +
+ ) + } - {results.length > 0 && ( - <> -
- - {results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''} - -
-
- {results.map((result, i) => { - const isA = entryLookup.get(result.path)?.isA ?? result.noteType - const noteType = isA && isA !== 'Note' ? isA : null - const typeColor = noteType ? getTypeColor(isA, typeEntryMap[isA ?? '']?.color) : undefined - const typeLightColor = noteType ? getTypeLightColor(isA, typeEntryMap[isA ?? '']?.color) : undefined - return ( -
onSelect(result)} - onMouseEnter={() => onHover(i)} - > -
- {result.title} - {noteType && ( - - {noteType} - - )} -
- {result.snippet && ( -

- {result.snippet} -

- )} -
- ) - })} -
- - )} + return ( +
+
) } diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index 2ebeebf4..6545ed3e 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -35,7 +35,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'], }, { path: '/Users/luca/Laputa/responsibility/grow-newsletter.md', @@ -69,7 +69,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'], }, { path: '/Users/luca/Laputa/responsibility/manage-sponsorships.md', @@ -97,7 +97,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['person/matteo-cellini'], }, { path: '/Users/luca/Laputa/procedure/write-weekly-essays.md', @@ -125,7 +125,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['responsibility/grow-newsletter'], }, { path: '/Users/luca/Laputa/procedure/run-sponsorships.md', @@ -153,7 +153,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['responsibility/manage-sponsorships'], }, { path: '/Users/luca/Laputa/experiment/stock-screener.md', @@ -182,7 +182,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'], }, { path: '/Users/luca/Laputa/note/facebook-ads-strategy.md', @@ -211,7 +211,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'], }, { path: '/Users/luca/Laputa/note/budget-allocation.md', @@ -239,7 +239,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['project/26q1-laputa-app'], }, { path: '/Users/luca/Laputa/person/matteo-cellini.md', @@ -375,7 +375,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'], }, { path: '/Users/luca/Laputa/topic/software-development.md', @@ -459,7 +459,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['responsibility/grow-newsletter'], }, { path: '/Users/luca/Laputa/essay/engineering-leadership-101.md', @@ -488,7 +488,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'], }, { path: '/Users/luca/Laputa/essay/ai-agents-primer.md', @@ -516,7 +516,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: ['responsibility/grow-newsletter'], }, // --- Type documents --- { @@ -1041,7 +1041,7 @@ function generateBulkEntries(count: number): VaultEntry[] { icon: null, color: null, order: null, - outgoingLinks: [], + outgoingLinks: Array.from({ length: i % 8 }, (_, j) => `note/link-target-${(i + j) % 50}`), }) } return entries diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index 0edf7ec4..fc2c5a0f 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { formatSubtitle, relativeDate } from './noteListHelpers' +import { formatSubtitle, formatSearchSubtitle, relativeDate } from './noteListHelpers' import type { VaultEntry } from '../types' function makeEntry(overrides: Partial = {}): VaultEntry { @@ -48,6 +48,80 @@ describe('formatSubtitle', () => { expect(result).toContain('50 words') expect(result).toContain('\u00b7') }) + + it('includes link count when outgoingLinks is non-empty', () => { + const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 200, outgoingLinks: ['a', 'b', 'c'] }) + const result = formatSubtitle(entry) + expect(result).toContain('3 links') + }) + + it('uses singular "link" when exactly one', () => { + const entry = makeEntry({ wordCount: 100, outgoingLinks: ['one'] }) + expect(formatSubtitle(entry)).toContain('1 link') + expect(formatSubtitle(entry)).not.toContain('1 links') + }) + + it('omits link count when outgoingLinks is empty', () => { + const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] }) + expect(formatSubtitle(entry)).not.toContain('link') + }) + + it('formats word count with locale separators for large numbers', () => { + const entry = makeEntry({ wordCount: 1240 }) + const result = formatSubtitle(entry) + expect(result).toMatch(/1,?240 words/) + }) +}) + +describe('formatSearchSubtitle', () => { + afterEach(() => { vi.restoreAllMocks() }) + + it('shows modified date, created date, word count, and links', () => { + const now = Math.floor(Date.now() / 1000) + const entry = makeEntry({ + modifiedAt: now - 3600, + createdAt: now - 86400 * 30, + wordCount: 520, + outgoingLinks: ['a', 'b', 'c', 'd', 'e'], + }) + const result = formatSearchSubtitle(entry) + expect(result).toContain('1h ago') + expect(result).toContain('Created') + expect(result).toContain('520 words') + expect(result).toContain('5 links') + }) + + it('omits created date when same as modified', () => { + const now = Math.floor(Date.now() / 1000) + const entry = makeEntry({ modifiedAt: now, createdAt: now, wordCount: 100 }) + const result = formatSearchSubtitle(entry) + expect(result).not.toContain('Created') + }) + + it('omits created date when createdAt is null', () => { + const now = Math.floor(Date.now() / 1000) + const entry = makeEntry({ modifiedAt: now, createdAt: null, wordCount: 100 }) + const result = formatSearchSubtitle(entry) + expect(result).not.toContain('Created') + }) + + it('shows "Empty" for zero word count', () => { + const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 }) + expect(formatSearchSubtitle(entry)).toContain('Empty') + }) + + it('omits link count when no outgoing links', () => { + const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] }) + expect(formatSearchSubtitle(entry)).not.toContain('link') + }) + + it('falls back to createdAt when modifiedAt is null', () => { + const entry = makeEntry({ createdAt: 1700000000, wordCount: 200, outgoingLinks: ['a'] }) + const result = formatSearchSubtitle(entry) + expect(result).toContain('200 words') + expect(result).toContain('1 link') + expect(result).not.toContain('Created') + }) }) describe('relativeDate', () => { diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index ba46ae90..68b559ae 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -30,10 +30,31 @@ export function formatSubtitle(entry: VaultEntry): string { const date = getDisplayDate(entry) if (date) parts.push(relativeDate(date)) if (entry.wordCount > 0) { - parts.push(`${entry.wordCount} words`) + parts.push(`${entry.wordCount.toLocaleString()} words`) } else { parts.push('Empty') } + if (entry.outgoingLinks.length > 0) { + parts.push(`${entry.outgoingLinks.length} ${entry.outgoingLinks.length === 1 ? 'link' : 'links'}`) + } + return parts.join(' \u00b7 ') +} + +export function formatSearchSubtitle(entry: VaultEntry): string { + const parts: string[] = [] + const modified = entry.modifiedAt ?? entry.createdAt + if (modified) parts.push(relativeDate(modified)) + if (entry.createdAt && entry.modifiedAt && entry.createdAt !== entry.modifiedAt) { + parts.push(`Created ${relativeDate(entry.createdAt)}`) + } + if (entry.wordCount > 0) { + parts.push(`${entry.wordCount.toLocaleString()} words`) + } else { + parts.push('Empty') + } + if (entry.outgoingLinks.length > 0) { + parts.push(`${entry.outgoingLinks.length} ${entry.outgoingLinks.length === 1 ? 'link' : 'links'}`) + } return parts.join(' \u00b7 ') }