feat: note subtitle — show metadata (date + word count) (#94)
* feat: show metadata subtitle (date + word count) instead of snippet Replace the note list subtitle with a compact metadata summary showing relative date and word count (e.g. "2d ago · 342 words") instead of the first paragraph of note content. Adds word_count to VaultEntry on the Rust backend, computed during vault scan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: cargo fmt formatting --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ const baseEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -46,6 +46,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -70,6 +70,7 @@ const mockEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -21,6 +21,7 @@ const mockEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -64,6 +65,7 @@ const referrerEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -363,6 +365,7 @@ This is a test note with some words to count.
|
||||
createdAt: null,
|
||||
fileSize: 500,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Type': ['[[type/responsibility]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -388,6 +391,7 @@ This is a test note with some words to count.
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/essay]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -413,6 +417,7 @@ This is a test note with some words to count.
|
||||
createdAt: null,
|
||||
fileSize: 400,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/procedure]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -438,6 +443,7 @@ This is a test note with some words to count.
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Related to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/experiment]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -596,6 +602,7 @@ Status: Active
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/essay]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
import { relativeDate, formatSubtitle } from '../utils/noteListHelpers'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
@@ -101,12 +101,9 @@ export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
{entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
: <div className="mt-1 text-[11px] text-muted-foreground">{formatSubtitle(entry)}</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: 'Build a personal knowledge management app.',
|
||||
wordCount: 0,
|
||||
relationships: {
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
},
|
||||
@@ -53,6 +54,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 847,
|
||||
snippet: 'Lookalike audiences convert 3x better.',
|
||||
wordCount: 0,
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Related to': ['[[topic/growth]]'],
|
||||
@@ -80,6 +82,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 320,
|
||||
snippet: 'Sponsorship manager.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -104,6 +107,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
snippet: 'Project kickoff meeting notes.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -128,6 +132,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Frontend, backend, and systems programming.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -261,12 +266,12 @@ describe('NoteList', () => {
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('context view shows prominent card with entity snippet', () => {
|
||||
it('context view shows prominent card with metadata subtitle', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Snippet appears in the prominent card
|
||||
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
|
||||
// Metadata subtitle (date · word count) appears in the prominent card
|
||||
expect(screen.getAllByText(/Empty/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -301,7 +306,9 @@ describe('NoteList click behavior', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Build a personal knowledge management app.'), { metaKey: true })
|
||||
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -310,7 +317,9 @@ describe('NoteList click behavior', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Build a personal knowledge management app.'))
|
||||
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1])
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -344,6 +353,7 @@ describe('getSortComparator', () => {
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -455,6 +465,7 @@ describe('NoteList sort controls', () => {
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -648,6 +659,7 @@ const trashedEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 280,
|
||||
snippet: 'Some draft content that is no longer needed.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -673,6 +685,7 @@ const expiredTrashedEntry: VaultEntry = {
|
||||
createdAt: null,
|
||||
fileSize: 190,
|
||||
snippet: 'Old API docs replaced by v2.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -827,6 +840,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
createdAt: null,
|
||||
fileSize: 500,
|
||||
snippet: `Content of note ${i}`,
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
|
||||
getSortComparator,
|
||||
buildRelationshipGroups, filterEntries,
|
||||
relativeDate, getDisplayDate,
|
||||
formatSubtitle,
|
||||
loadSortPreferences, saveSortPreferences,
|
||||
} from '../utils/noteListHelpers'
|
||||
|
||||
@@ -29,11 +29,10 @@ interface NoteListProps {
|
||||
onCreateNote: () => void
|
||||
}
|
||||
|
||||
function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
|
||||
function PinnedCard({ entry, typeEntryMap, onClickNote }: {
|
||||
entry: VaultEntry
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
showDate?: boolean
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const color = getTypeColor(entry.isA ?? '', te?.color)
|
||||
@@ -44,8 +43,7 @@ function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
|
||||
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
|
||||
{showDate && <div className="mt-1 text-[11px] opacity-60" style={{ color }}>{relativeDate(getDisplayDate(entry))}</div>}
|
||||
<div className="mt-1 text-[11px] opacity-60" style={{ color }}>{formatSubtitle(entry)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -120,7 +118,7 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onClickNote={onClickNote} showDate />
|
||||
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />
|
||||
{groups.length === 0
|
||||
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
|
||||
: groups.map((group) => (
|
||||
|
||||
@@ -21,6 +21,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -31,6 +31,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: Date.now() / 1000,
|
||||
fileSize: 500,
|
||||
snippet: 'A guide to designing APIs for AI',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -55,6 +56,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
createdAt: Date.now() / 1000,
|
||||
fileSize: 300,
|
||||
snippet: 'Team retreat event',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -58,6 +59,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -82,6 +84,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -106,6 +109,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 128,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -130,6 +134,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -154,6 +159,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 180,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -178,6 +184,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -202,6 +209,7 @@ const mockEntries: VaultEntry[] = [
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -385,6 +393,7 @@ describe('Sidebar', () => {
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -409,6 +418,7 @@ describe('Sidebar', () => {
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -433,6 +443,7 @@ describe('Sidebar', () => {
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -486,6 +497,7 @@ describe('Sidebar', () => {
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
@@ -603,18 +615,21 @@ describe('Sidebar', () => {
|
||||
path: '/vault/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 5, outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 0, outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 1, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ function makeEntry(path: string, title: string): VaultEntry {
|
||||
status: null, owner: null, cadence: null, archived: false,
|
||||
trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', relationships: {}, icon: null, color: null, order: null,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user