refactor: extract SearchResultItem and SearchResultsList from SearchContent (#124)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Luca Rossi
2026-02-27 15:27:11 +01:00
committed by GitHub
parent f3629e24d5
commit 80dd525898
6 changed files with 695 additions and 80 deletions

View File

@@ -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"}]}
}}

View File

@@ -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(
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
)
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(

View File

@@ -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<string, { isA: string | null }>()
for (const e of entries) map.set(e.path, { isA: e.isA })
const map = new Map<string, VaultEntry>()
for (const e of entries) map.set(e.path, e)
return map
}, [entries])
@@ -142,13 +143,92 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
},
)
function SearchResultItem({ result, isSelected, entry, typeEntryMap, onSelect, onHover }: {
result: SearchResult
isSelected: boolean
entry: VaultEntry | undefined
typeEntryMap: Record<string, VaultEntry>
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 (
<div
className={cn(
"cursor-pointer px-4 py-2.5 transition-colors",
isSelected ? "bg-accent" : "hover:bg-secondary",
)}
onClick={onSelect}
onMouseEnter={onHover}
>
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
{noteType && (
<Badge variant="secondary" className="text-[10px]" style={typeColor ? { color: typeColor, backgroundColor: typeLightColor } : undefined}>
{noteType}
</Badge>
)}
</div>
{result.snippet && (
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
{result.snippet}
</p>
)}
{subtitle && (
<p className="mt-1 text-[11px] text-muted-foreground/70" data-testid="search-result-meta">
{subtitle}
</p>
)}
</div>
)
}
function SearchResultsList({ results, selectedIndex, entryLookup, typeEntryMap, elapsedMs, listRef, onSelect, onHover }: {
results: SearchResult[]
selectedIndex: number
entryLookup: Map<string, VaultEntry>
typeEntryMap: Record<string, VaultEntry>
elapsedMs: number | null
listRef: React.RefObject<HTMLDivElement | null>
onSelect: (result: SearchResult) => void
onHover: (index: number) => void
}) {
return (
<>
<div className="border-b border-border/50 px-4 py-1.5">
<span className="text-[11px] text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
</span>
</div>
<div ref={listRef}>
{results.map((result, i) => (
<SearchResultItem
key={result.path}
result={result}
isSelected={i === selectedIndex}
entry={entryLookup.get(result.path)}
typeEntryMap={typeEntryMap}
onSelect={() => onSelect(result)}
onHover={() => onHover(i)}
/>
))}
</div>
</>
)
}
interface SearchContentProps {
query: string
results: SearchResult[]
selectedIndex: number
loading: boolean
elapsedMs: number | null
entryLookup: Map<string, { isA: string | null }>
entryLookup: Map<string, VaultEntry>
typeEntryMap: Record<string, VaultEntry>
listRef: React.RefObject<HTMLDivElement | null>
onSelect: (result: SearchResult) => void
@@ -158,71 +238,50 @@ interface SearchContentProps {
function SearchContent({
query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover,
}: SearchContentProps) {
return (
<div className="flex-1 overflow-y-auto">
{!query.trim() && (
const hasQuery = query.trim().length > 0
const hasResults = results.length > 0
if (!hasQuery) {
return (
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">Search across all note contents</p>
<p className="mt-1 text-[11px] text-muted-foreground/60">
Enter to open · Esc to close
</p>
<p className="mt-1 text-[11px] text-muted-foreground/60">Enter to open · Esc to close</p>
</div>
)}
</div>
)
}
{query.trim() && results.length === 0 && loading && (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
Searching...
</div>
)}
if (!hasResults && loading) {
return (
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">Searching...</div>
</div>
)
}
{query.trim() && results.length === 0 && !loading && (
if (!hasResults) {
return (
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">No results found</p>
</div>
)}
</div>
)
}
{results.length > 0 && (
<>
<div className="border-b border-border/50 px-4 py-1.5">
<span className="text-[11px] text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
</span>
</div>
<div ref={listRef}>
{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 (
<div
key={result.path}
className={cn(
"cursor-pointer px-4 py-2.5 transition-colors",
i === selectedIndex ? "bg-accent" : "hover:bg-secondary",
)}
onClick={() => onSelect(result)}
onMouseEnter={() => onHover(i)}
>
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
{noteType && (
<Badge variant="secondary" className="text-[10px]" style={typeColor ? { color: typeColor, backgroundColor: typeLightColor } : undefined}>
{noteType}
</Badge>
)}
</div>
{result.snippet && (
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
{result.snippet}
</p>
)}
</div>
)
})}
</div>
</>
)}
return (
<div className="flex-1 overflow-y-auto">
<SearchResultsList
results={results}
selectedIndex={selectedIndex}
entryLookup={entryLookup}
typeEntryMap={typeEntryMap}
elapsedMs={elapsedMs}
listRef={listRef}
onSelect={onSelect}
onHover={onHover}
/>
</div>
)
}

View File

@@ -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

View File

@@ -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> = {}): 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', () => {

View File

@@ -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 ')
}