diff --git a/e2e/filtering.spec.ts b/e2e/filtering.spec.ts
new file mode 100644
index 00000000..aa1fe65e
--- /dev/null
+++ b/e2e/filtering.spec.ts
@@ -0,0 +1,56 @@
+import { test, expect } from '@playwright/test'
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+ await page.waitForTimeout(500) // Wait for mock data
+})
+
+test('All Notes shows all entries', async ({ page }) => {
+ const count = page.locator('.note-list__count')
+ await expect(count).toHaveText('12')
+})
+
+test('clicking People filter shows only people', async ({ page }) => {
+ await page.click('text=People')
+ await page.waitForTimeout(100)
+ const count = page.locator('.note-list__count')
+ await expect(count).toHaveText('1')
+ await expect(page.locator('.note-list__title', { hasText: 'Matteo Cellini' })).toBeVisible()
+ await page.screenshot({ path: 'test-results/filter-people.png' })
+})
+
+test('clicking Events filter shows only events', async ({ page }) => {
+ await page.click('text=Events')
+ await page.waitForTimeout(100)
+ const count = page.locator('.note-list__count')
+ await expect(count).toHaveText('1')
+ await expect(page.locator('.note-list__title', { hasText: 'Laputa App Design Session' })).toBeVisible()
+ await page.screenshot({ path: 'test-results/filter-events.png' })
+})
+
+test('clicking PROJECTS header shows all projects', async ({ page }) => {
+ await page.click('text=PROJECTS')
+ await page.waitForTimeout(100)
+ const count = page.locator('.note-list__count')
+ await expect(count).toHaveText('1')
+ await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
+ await page.screenshot({ path: 'test-results/filter-projects.png' })
+})
+
+test('clicking specific entity shows it pinned with children', async ({ page }) => {
+ await page.locator('.sidebar__item', { hasText: 'Build Laputa App' }).click()
+ await page.waitForTimeout(100)
+ // Pinned entity + children that belongTo this project
+ await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
+ await expect(page.locator('.note-list__title', { hasText: 'Facebook Ads Strategy' })).toBeVisible()
+ await expect(page.locator('.note-list__title', { hasText: 'Budget Allocation' })).toBeVisible()
+ await expect(page.locator('.note-list__item--pinned')).toBeVisible()
+ await page.screenshot({ path: 'test-results/filter-entity.png' })
+})
+
+test('clicking topic shows entries related to that topic', async ({ page }) => {
+ await page.locator('.sidebar__topic-item', { hasText: 'Software Development' }).click()
+ await page.waitForTimeout(100)
+ await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
+ await page.screenshot({ path: 'test-results/filter-topic.png' })
+})
diff --git a/src/App.tsx b/src/App.tsx
index fd360e53..bc1a954c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -63,7 +63,7 @@ function App() {
diff --git a/src/components/NoteList.css b/src/components/NoteList.css
index c3af8a88..76102c8b 100644
--- a/src/components/NoteList.css
+++ b/src/components/NoteList.css
@@ -72,3 +72,9 @@
.note-list__status {
color: #6a6;
}
+
+.note-list__item--pinned {
+ background: #1e1e3a;
+ border-left: 3px solid #6a6;
+ padding-left: 13px;
+}
diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx
index fcc0d852..b23ed01d 100644
--- a/src/components/NoteList.test.tsx
+++ b/src/components/NoteList.test.tsx
@@ -1,38 +1,135 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { NoteList } from './NoteList'
+import type { VaultEntry, SidebarSelection } from '../types'
+
+const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
+
+const mockEntries: VaultEntry[] = [
+ {
+ path: '/Users/luca/Laputa/project/26q1-laputa-app.md',
+ filename: '26q1-laputa-app.md',
+ title: 'Build Laputa App',
+ isA: 'Project',
+ aliases: [],
+ belongsTo: [],
+ relatedTo: ['[[topic/software-development]]'],
+ status: 'Active',
+ owner: 'Luca',
+ cadence: null,
+ modifiedAt: 1700000000,
+ fileSize: 1024,
+ },
+ {
+ path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
+ filename: 'facebook-ads-strategy.md',
+ title: 'Facebook Ads Strategy',
+ isA: 'Note',
+ aliases: [],
+ belongsTo: ['[[project/26q1-laputa-app]]'],
+ relatedTo: ['[[topic/growth]]'],
+ status: null,
+ owner: null,
+ cadence: null,
+ modifiedAt: 1700000000,
+ fileSize: 847,
+ },
+ {
+ path: '/Users/luca/Laputa/person/matteo-cellini.md',
+ filename: 'matteo-cellini.md',
+ title: 'Matteo Cellini',
+ isA: 'Person',
+ aliases: [],
+ belongsTo: [],
+ relatedTo: [],
+ status: null,
+ owner: null,
+ cadence: null,
+ modifiedAt: 1700000000,
+ fileSize: 320,
+ },
+ {
+ path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
+ filename: '2026-02-14-kickoff.md',
+ title: 'Kickoff Meeting',
+ isA: 'Event',
+ aliases: [],
+ belongsTo: [],
+ relatedTo: [],
+ status: null,
+ owner: null,
+ cadence: null,
+ modifiedAt: 1700000000,
+ fileSize: 512,
+ },
+ {
+ path: '/Users/luca/Laputa/topic/software-development.md',
+ filename: 'software-development.md',
+ title: 'Software Development',
+ isA: 'Topic',
+ aliases: [],
+ belongsTo: [],
+ relatedTo: [],
+ status: null,
+ owner: null,
+ cadence: null,
+ modifiedAt: 1700000000,
+ fileSize: 256,
+ },
+]
describe('NoteList', () => {
it('shows empty state when no entries', () => {
- render(
)
- expect(screen.getByText('No notes loaded')).toBeInTheDocument()
+ render(
)
+ expect(screen.getByText('No notes found')).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
- it('renders note entries', () => {
- const entries = [
- {
- path: '/vault/note1.md',
- filename: 'note1.md',
- title: 'First Note',
- isA: 'Project',
- status: 'Active',
- modifiedAt: 1700000000,
- },
- {
- path: '/vault/note2.md',
- filename: 'note2.md',
- title: 'Second Note',
- isA: null,
- status: null,
- modifiedAt: null,
- },
- ]
- render(
)
- expect(screen.getByText('First Note')).toBeInTheDocument()
- expect(screen.getByText('Second Note')).toBeInTheDocument()
- expect(screen.getByText('Project')).toBeInTheDocument()
- expect(screen.getByText('Active')).toBeInTheDocument()
+ it('renders all entries with All Notes filter', () => {
+ render(
)
+ expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
+ expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
+ expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+
+ it('filters by People', () => {
+ render(
)
+ expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
+ expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
+ expect(screen.getByText('1')).toBeInTheDocument()
+ })
+
+ it('filters by Events', () => {
+ render(
)
+ expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
+ expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
+ })
+
+ it('filters by section group type', () => {
+ render(
)
+ expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
+ expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
+ expect(screen.getByText('1')).toBeInTheDocument()
+ })
+
+ it('shows entity pinned at top with children', () => {
+ render(
+
+ )
+ // Pinned entity + child (Facebook Ads Strategy belongsTo this project)
+ expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
+ expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
+ expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
+
+ it('filters by topic (relatedTo references)', () => {
+ render(
+
+ )
+ // Build Laputa App has relatedTo: [[topic/software-development]]
+ expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
+ expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
+ })
})
diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx
index 00cf9c63..6e168d0b 100644
--- a/src/components/NoteList.tsx
+++ b/src/components/NoteList.tsx
@@ -1,31 +1,75 @@
+import type { VaultEntry, SidebarSelection } from '../types'
import './NoteList.css'
-interface VaultEntry {
- path: string
- filename: string
- title: string
- isA: string | null
- status: string | null
- modifiedAt: number | null
-}
-
interface NoteListProps {
entries: VaultEntry[]
+ selection: SidebarSelection
}
-export function NoteList({ entries }: NoteListProps) {
+/** Check if a wikilink array (e.g. belongsTo) references a given entry by path stem */
+function refsMatch(refs: string[], entry: VaultEntry): boolean {
+ // Extract the path stem: /Users/luca/Laputa/project/26q1-laputa-app.md → project/26q1-laputa-app
+ const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
+ return refs.some((ref) => {
+ const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
+ return inner === stem
+ })
+}
+
+function filterEntries(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
+ switch (selection.kind) {
+ case 'filter':
+ switch (selection.filter) {
+ case 'all':
+ return entries
+ case 'people':
+ return entries.filter((e) => e.isA === 'Person')
+ case 'events':
+ return entries.filter((e) => e.isA === 'Event')
+ case 'favorites':
+ // TODO: Implement favorites (needs a "favorite" field in frontmatter)
+ return []
+ case 'trash':
+ // TODO: Implement trash (needs deleted/archived status)
+ return []
+ }
+ 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 'topic': {
+ const topic = selection.entry
+ return entries.filter((e) => refsMatch(e.relatedTo, topic))
+ }
+ }
+}
+
+export function NoteList({ entries, selection }: NoteListProps) {
+ const filtered = filterEntries(entries, selection)
+
return (
Notes
- {entries.length}
+ {filtered.length}
- {entries.length === 0 ? (
-
No notes loaded
+ {filtered.length === 0 ? (
+
No notes found
) : (
- entries.map((entry) => (
-
+ filtered.map((entry, i) => (
+
{entry.title}
{entry.isA && {entry.isA}}