diff --git a/src/components/NoteList.css b/src/components/NoteList.css index d44041c4..d8575e3c 100644 --- a/src/components/NoteList.css +++ b/src/components/NoteList.css @@ -26,6 +26,11 @@ margin: 0; font-size: 14px; font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; } .note-list__header-right { @@ -270,3 +275,31 @@ border-left: 3px solid #4caf50; padding-left: 13px; } + +/* Relationship groups */ +.note-list__group { + border-top: 1px solid rgba(42, 42, 74, 0.5); +} + +.note-list__group-header { + padding: 10px 16px 4px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.note-list__group-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #666; +} + +.note-list__group-count { + font-size: 10px; + color: #555; + background: #2a2a4a; + padding: 1px 6px; + border-radius: 8px; +} diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 46ec5da3..7892b368 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -114,15 +114,23 @@ describe('NoteList', () => { expect(container.querySelector('.note-list__count')!.textContent).toBe('1') }) - it('shows entity pinned at top with children', () => { + it('shows entity pinned at top with grouped children', () => { const { container } = render( ) - // Pinned entity + child (Facebook Ads Strategy belongsTo this project) - expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + // Entity title appears in header and pinned card + expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1) + // Child entry in "Children" group expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + // Unrelated entries not shown expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() + // Group headers shown + expect(screen.getByText('Children')).toBeInTheDocument() + expect(screen.getByText('Related To')).toBeInTheDocument() + // Count shows grouped items (1 child + 1 related) expect(container.querySelector('.note-list__count')!.textContent).toBe('2') + // Type pills hidden in entity view + expect(container.querySelector('.note-list__pills')).toBeNull() }) it('filters by topic (relatedTo references)', () => { diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 2a36cb4d..da3b33f1 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -11,6 +11,11 @@ interface NoteListProps { onCreateNote: () => void } +interface RelationshipGroup { + label: string + entries: VaultEntry[] +} + /** Extract first ~80 chars of content after the title heading */ function getSnippet(content: string | undefined): string { if (!content) return '' @@ -60,6 +65,80 @@ function refsMatch(refs: string[], entry: VaultEntry): boolean { }) } +/** Resolve wikilink references to actual VaultEntry objects */ +function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] { + return refs + .map((ref) => { + const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '') + return entries.find((e) => { + const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + if (stem === inner) return true + const fileStem = e.filename.replace(/\.md$/, '') + if (fileStem === inner.split('/').pop()) return true + return false + }) + }) + .filter((e): e is VaultEntry => e !== undefined) +} + +function sortByModified(a: VaultEntry, b: VaultEntry): number { + return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) +} + +/** Build relationship groups for an entity view */ +function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]): RelationshipGroup[] { + const groups: RelationshipGroup[] = [] + const seen = new Set([entity.path]) + + // 1. Children: items whose belongsTo references this entity (non-events) + const children = allEntries + .filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity)) + .sort(sortByModified) + if (children.length > 0) { + groups.push({ label: 'Children', entries: children }) + children.forEach((e) => seen.add(e.path)) + } + + // 2. Events that reference this entity (via belongsTo or relatedTo) + const events = allEntries + .filter( + (e) => + !seen.has(e.path) && + e.isA === 'Event' && + (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)) + ) + .sort(sortByModified) + if (events.length > 0) { + groups.push({ label: 'Events', entries: events }) + events.forEach((e) => seen.add(e.path)) + } + + // 3. Referenced By: non-event items whose relatedTo references this entity + const referencedBy = allEntries + .filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity)) + .sort(sortByModified) + if (referencedBy.length > 0) { + groups.push({ label: 'Referenced By', entries: referencedBy }) + referencedBy.forEach((e) => seen.add(e.path)) + } + + // 4. Belongs To: resolve this entity's own belongsTo references + const belongsTo = resolveRefs(entity.belongsTo, allEntries).filter((e) => !seen.has(e.path)) + if (belongsTo.length > 0) { + groups.push({ label: 'Belongs To', entries: belongsTo }) + belongsTo.forEach((e) => seen.add(e.path)) + } + + // 5. Related To: resolve this entity's own relatedTo references + const relatedTo = resolveRefs(entity.relatedTo, allEntries).filter((e) => !seen.has(e.path)) + if (relatedTo.length > 0) { + groups.push({ label: 'Related To', entries: relatedTo }) + relatedTo.forEach((e) => seen.add(e.path)) + } + + return groups +} + function filterEntries(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] { switch (selection.kind) { case 'filter': @@ -80,13 +159,9 @@ function filterEntries(entries: VaultEntry[], selection: SidebarSelection): Vaul break case 'sectionGroup': return entries.filter((e) => e.isA === selection.type) - case 'entity': { - const pinned = selection.entry - const children = entries.filter( - (e) => e.path !== pinned.path && refsMatch(e.belongsTo, pinned) - ) - return [pinned, ...children] - } + case 'entity': + // Handled separately via buildRelationshipGroups + return [] case 'topic': { const topic = selection.entry return entries.filter((e) => refsMatch(e.relatedTo, topic)) @@ -94,10 +169,6 @@ function filterEntries(entries: VaultEntry[], selection: SidebarSelection): Vaul } } -function sortByModified(a: VaultEntry, b: VaultEntry): number { - return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) -} - const TYPE_PILLS = [ { label: 'All', type: null }, { label: 'Projects', type: 'Project' }, @@ -113,19 +184,31 @@ export function NoteList({ entries, selection, selectedNote, allContent, onSelec const [search, setSearch] = useState('') const [typeFilter, setTypeFilter] = useState(null) - const filtered = filterEntries(entries, selection) + const isEntityView = selection.kind === 'entity' - // Sort: for entity view, keep pinned first, sort children; otherwise sort all - let sorted: VaultEntry[] - if (selection.kind === 'entity' && filtered.length > 0) { - const [pinned, ...children] = filtered - sorted = [pinned, ...children.sort(sortByModified)] - } else { - sorted = [...filtered].sort(sortByModified) - } + // Entity view: build relationship groups + const entityGroups = isEntityView + ? buildRelationshipGroups(selection.entry, entries) + : [] - // Search filter (title substring, case-insensitive) + // Non-entity view: flat filtered list + const filtered = isEntityView ? [] : filterEntries(entries, selection) + const sorted = isEntityView ? [] : [...filtered].sort(sortByModified) + + // Search filter const query = search.trim().toLowerCase() + + // For entity view: filter within groups + const searchedGroups = query + ? entityGroups + .map((g) => ({ + ...g, + entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)), + })) + .filter((g) => g.entries.length > 0) + : entityGroups + + // For flat view const searched = query ? sorted.filter((e) => e.title.toLowerCase().includes(query)) : sorted @@ -139,17 +222,42 @@ export function NoteList({ entries, selection, selectedNote, allContent, onSelec } } - // Type filter pills + // Type filter pills (flat view only) const displayed = typeFilter ? searched.filter((e) => e.isA === typeFilter) : searched + // Total count for header + const totalCount = isEntityView + ? searchedGroups.reduce((sum, g) => sum + g.entries.length, 0) + : displayed.length + + const renderItem = (entry: VaultEntry, isPinned = false) => ( +
onSelectNote(entry)} + > +
+
{entry.title}
+ {relativeDate(getDisplayDate(entry))} +
+
{getSnippet(allContent[entry.path])}
+
+ {entry.isA && {entry.isA}} + {entry.status && {entry.status}} +
+
+ ) + return (
-

Notes

+

{isEntityView ? selection.entry.title : 'Notes'}

- {displayed.length} + {totalCount} @@ -164,46 +272,51 @@ export function NoteList({ entries, selection, selectedNote, allContent, onSelec onChange={(e) => setSearch(e.target.value)} />
-
- {TYPE_PILLS.filter(({ type }) => { - const count = typeCounts.get(type) ?? 0 - return type === null || count > 0 - }).map(({ label, type }) => { - const count = typeCounts.get(type) ?? 0 - return ( - - ) - })} -
+ {!isEntityView && ( +
+ {TYPE_PILLS.filter(({ type }) => { + const count = typeCounts.get(type) ?? 0 + return type === null || count > 0 + }).map(({ label, type }) => { + const count = typeCounts.get(type) ?? 0 + return ( + + ) + })} +
+ )}
- {displayed.length === 0 ? ( -
No notes found
+ {isEntityView ? ( + <> + {renderItem(selection.entry, true)} + {searchedGroups.length === 0 ? ( +
+ {query ? 'No matching items' : 'No related items'} +
+ ) : ( + searchedGroups.map((group) => ( +
+
+ {group.label} + {group.entries.length} +
+ {group.entries.map((entry) => renderItem(entry))} +
+ )) + )} + ) : ( - displayed.map((entry, i) => ( -
onSelectNote(entry)} - > -
-
{entry.title}
- {relativeDate(getDisplayDate(entry))} -
-
{getSnippet(allContent[entry.path])}
-
- {entry.isA && {entry.isA}} - {entry.status && {entry.status}} -
-
- )) + displayed.length === 0 ? ( +
No notes found
+ ) : ( + displayed.map((entry) => renderItem(entry)) + ) )}