From afe4e91f0dfa663476486ea87c46d2da383a9a9e Mon Sep 17 00:00:00 2001 From: Danny Smith Date: Fri, 27 Mar 2026 23:51:11 +0000 Subject: [PATCH 1/3] Fix sidebar reordering on vault data changes (Closes #51) Two root causes: HashMap non-deterministic iteration in Rust and rayon parallel scan ordering meant the "natural order" of entities was effectively random on every refresh. Additionally, new items created on disk were invisible in the sidebar when a manual order existed. Fixes: - Sort entity lists alphabetically by title in Rust VaultIndex for stable default ordering - Append new entity IDs to stored sidebar order instead of silently dropping them Co-Authored-By: Claude Opus 4.6 (1M context) --- tdn-desktop/src-tauri/src/vault/manager.rs | 18 ++++++--- .../src/hooks/use-sidebar-order.test.ts | 11 +++-- tdn-desktop/src/hooks/use-sidebar-order.ts | 40 ++++++++++++------- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/tdn-desktop/src-tauri/src/vault/manager.rs b/tdn-desktop/src-tauri/src/vault/manager.rs index cb9baabe..142919e5 100644 --- a/tdn-desktop/src-tauri/src/vault/manager.rs +++ b/tdn-desktop/src-tauri/src/vault/manager.rs @@ -95,19 +95,25 @@ impl VaultIndex { self.areas.get(id) } - /// Get all tasks + /// Get all tasks, sorted alphabetically by title for deterministic ordering. pub fn all_tasks(&self) -> Vec { - self.tasks.values().cloned().collect() + let mut tasks: Vec = self.tasks.values().cloned().collect(); + tasks.sort_by_cached_key(|t| t.title.to_lowercase()); + tasks } - /// Get all projects + /// Get all projects, sorted alphabetically by title for deterministic ordering. pub fn all_projects(&self) -> Vec { - self.projects.values().cloned().collect() + let mut projects: Vec = self.projects.values().cloned().collect(); + projects.sort_by_cached_key(|p| p.title.to_lowercase()); + projects } - /// Get all areas + /// Get all areas, sorted alphabetically by title for deterministic ordering. pub fn all_areas(&self) -> Vec { - self.areas.values().cloned().collect() + let mut areas: Vec = self.areas.values().cloned().collect(); + areas.sort_by_cached_key(|a| a.title.to_lowercase()); + areas } /// Update a task in the index diff --git a/tdn-desktop/src/hooks/use-sidebar-order.test.ts b/tdn-desktop/src/hooks/use-sidebar-order.test.ts index 7ba83f39..ac06aedd 100644 --- a/tdn-desktop/src/hooks/use-sidebar-order.test.ts +++ b/tdn-desktop/src/hooks/use-sidebar-order.test.ts @@ -126,15 +126,17 @@ describe('useSidebarOrder', () => { expect(result.current.orderedAreas[1]!.id).toBe('area-1') }) - it('filters out deleted areas from stored order', () => { + it('filters out deleted areas and appends new areas', () => { useDisplayOrderStore.setState({ sidebarAreaOrder: ['area-nonexistent', 'area-1'], }) const { result } = renderHook(() => useSidebarOrder()) - expect(result.current.orderedAreas).toHaveLength(1) + // area-nonexistent is removed, area-1 keeps its position, area-2 is appended + expect(result.current.orderedAreas).toHaveLength(2) expect(result.current.orderedAreas[0]!.id).toBe('area-1') + expect(result.current.orderedAreas[1]!.id).toBe('area-2') }) it('excludes archived areas', () => { @@ -190,7 +192,7 @@ describe('useSidebarOrder', () => { expect(projects.map(p => p.id)).toEqual(['project-2', 'project-1']) }) - it('filters out deleted projects', () => { + it('filters out deleted projects and appends new projects', () => { useDisplayOrderStore.setState({ sidebarProjectOrder: { 'area-1': ['project-nonexistent', 'project-1'], @@ -200,7 +202,8 @@ describe('useSidebarOrder', () => { const { result } = renderHook(() => useSidebarOrder()) const projects = result.current.getOrderedProjects('area-1') - expect(projects.map(p => p.id)).toEqual(['project-1']) + // project-nonexistent is removed, project-1 keeps position, project-2 is appended + expect(projects.map(p => p.id)).toEqual(['project-1', 'project-2']) }) }) diff --git a/tdn-desktop/src/hooks/use-sidebar-order.ts b/tdn-desktop/src/hooks/use-sidebar-order.ts index 86991a0a..4f360d0a 100644 --- a/tdn-desktop/src/hooks/use-sidebar-order.ts +++ b/tdn-desktop/src/hooks/use-sidebar-order.ts @@ -41,8 +41,14 @@ export function useSidebarOrder() { // Compute effective area order const effectiveAreaOrder = useMemo(() => { if (sidebarAreaOrder) { - // Filter to only include IDs that still exist in data - return sidebarAreaOrder.filter(id => activeAreas.some(a => a.id === id)) + // Keep stored order (minus deleted), then append any new items at the end + const kept = sidebarAreaOrder.filter(id => + activeAreas.some(a => a.id === id) + ) + const newIds = activeAreas + .filter(a => !sidebarAreaOrder.includes(a.id)) + .map(a => a.id) + return [...kept, ...newIds] } return activeAreas.map(a => a.id) }, [sidebarAreaOrder, activeAreas]) @@ -50,21 +56,25 @@ export function useSidebarOrder() { // Compute effective project order for a container const getEffectiveProjectOrder = useCallback( (containerId: string): string[] => { + // Get the natural order for this container (used as fallback and for new items) + const naturalOrder = (() => { + if (containerId === ORPHAN_CONTAINER_ID) { + return projects.filter(p => !p.area).map(p => p.id) + } + const area = areas.find(a => a.id === containerId) + if (!area) return [] + return projects.filter(p => p.area?.includes(area.title)).map(p => p.id) + })() + if (sidebarProjectOrder?.[containerId]) { - // Filter to only include IDs that still exist - return sidebarProjectOrder[containerId].filter(id => - projects.some(p => p.id === id) - ) + // Keep stored order (minus deleted), then append any new items at the end + const storedOrder = sidebarProjectOrder[containerId] + const kept = storedOrder.filter(id => naturalOrder.includes(id)) + const newIds = naturalOrder.filter(id => !storedOrder.includes(id)) + return [...kept, ...newIds] } - // Default: projects in natural order - if (containerId === ORPHAN_CONTAINER_ID) { - return projects.filter(p => !p.area).map(p => p.id) - } - // Find area by ID to get its title for wikilink matching - // Project.area is a wikilink like "[[Finance]]", not an ID - const area = areas.find(a => a.id === containerId) - if (!area) return [] - return projects.filter(p => p.area?.includes(area.title)).map(p => p.id) + + return naturalOrder }, [sidebarProjectOrder, projects, areas] ) From 334b77fb2407b5e449e81568d8f87668cf62ca7e Mon Sep 17 00:00:00 2001 From: Danny Smith Date: Sat, 28 Mar 2026 00:10:45 +0000 Subject: [PATCH 2/3] Add sort order assertions to VaultIndex all_* tests Replace count-only assertions with tests that create items in reverse order and verify alphabetical output. Co-Authored-By: Claude Opus 4.6 (1M context) --- tdn-desktop/src-tauri/src/vault/manager.rs | 33 ++++++++++++++-------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/tdn-desktop/src-tauri/src/vault/manager.rs b/tdn-desktop/src-tauri/src/vault/manager.rs index 142919e5..a308bac2 100644 --- a/tdn-desktop/src-tauri/src/vault/manager.rs +++ b/tdn-desktop/src-tauri/src/vault/manager.rs @@ -881,39 +881,48 @@ mod tests { // ------------------------------------------------------------------------ #[test] - fn vault_index_all_tasks() { + fn vault_index_all_tasks_sorted_alphabetically() { let tasks = vec![ - test_task("task-1", "/tasks/task-1.md"), - test_task("task-2", "/tasks/task-2.md"), + test_task("z", "/tasks/z.md"), + test_task("a", "/tasks/a.md"), + test_task("m", "/tasks/m.md"), ]; let index = VaultIndex::from_scans(tasks, vec![], vec![]); let all = index.all_tasks(); - assert_eq!(all.len(), 2); + assert_eq!(all.len(), 3); + let titles: Vec<&str> = all.iter().map(|t| t.title.as_str()).collect(); + assert_eq!(titles, vec!["Task a", "Task m", "Task z"]); } #[test] - fn vault_index_all_projects() { + fn vault_index_all_projects_sorted_alphabetically() { let projects = vec![ - test_project("proj-1", "/projects/proj-1.md"), - test_project("proj-2", "/projects/proj-2.md"), + test_project("z", "/projects/z.md"), + test_project("a", "/projects/a.md"), + test_project("m", "/projects/m.md"), ]; let index = VaultIndex::from_scans(vec![], projects, vec![]); let all = index.all_projects(); - assert_eq!(all.len(), 2); + assert_eq!(all.len(), 3); + let titles: Vec<&str> = all.iter().map(|p| p.title.as_str()).collect(); + assert_eq!(titles, vec!["Project a", "Project m", "Project z"]); } #[test] - fn vault_index_all_areas() { + fn vault_index_all_areas_sorted_alphabetically() { let areas = vec![ - test_area("area-1", "/areas/area-1.md"), - test_area("area-2", "/areas/area-2.md"), + test_area("z", "/areas/z.md"), + test_area("a", "/areas/a.md"), + test_area("m", "/areas/m.md"), ]; let index = VaultIndex::from_scans(vec![], vec![], areas); let all = index.all_areas(); - assert_eq!(all.len(), 2); + assert_eq!(all.len(), 3); + let titles: Vec<&str> = all.iter().map(|a| a.title.as_str()).collect(); + assert_eq!(titles, vec!["Area a", "Area m", "Area z"]); } // ------------------------------------------------------------------------ From df693d3dcdba265ca9dd0746782b78c55ec0f36f Mon Sep 17 00:00:00 2001 From: Danny Smith Date: Sat, 28 Mar 2026 01:20:20 +0000 Subject: [PATCH 3/3] Fix wikilink substring matching across all views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace p.area?.includes(area.title) with exact wikilink comparison using a new matchesWikilinkTitle() utility. The substring check caused false positives — e.g., area "Work" would match projects in "[[Homework]]". Affects: sidebar ordering, queries, TodayView, WeekView, ProjectView, AreaView, NoAreaView, AreaKanbanBoard. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/kanban/AreaKanbanBoard.tsx | 3 ++- tdn-desktop/src/components/views/AreaView.tsx | 7 +++-- .../src/components/views/NoAreaView.tsx | 7 +++-- .../src/components/views/ProjectView.tsx | 4 +-- .../src/components/views/TodayView.tsx | 9 ++++--- tdn-desktop/src/components/views/WeekView.tsx | 12 ++++++--- tdn-desktop/src/hooks/use-sidebar-order.ts | 5 +++- tdn-desktop/src/lib/wikilink.test.ts | 27 +++++++++++++++++++ tdn-desktop/src/lib/wikilink.ts | 21 +++++++++++++++ tdn-desktop/src/services/vault/queries.ts | 18 ++++++++----- 10 files changed, 91 insertions(+), 22 deletions(-) diff --git a/tdn-desktop/src/components/kanban/AreaKanbanBoard.tsx b/tdn-desktop/src/components/kanban/AreaKanbanBoard.tsx index ae7100f0..209b348c 100644 --- a/tdn-desktop/src/components/kanban/AreaKanbanBoard.tsx +++ b/tdn-desktop/src/components/kanban/AreaKanbanBoard.tsx @@ -4,6 +4,7 @@ import { useDroppable } from '@dnd-kit/core' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { cn } from '@/lib/utils' +import { matchesWikilinkTitle } from '@/lib/wikilink' import type { Task, TaskStatus, Project } from '@/lib/tauri-bindings' import { taskStatusConfig } from '@/config/status' import { SortableKanbanCard } from './KanbanColumn' @@ -168,7 +169,7 @@ export function AreaKanbanBoard({ } // swimlaneId is a project ID - find the project and check if task.project matches const project = projects.find(p => p.id === swimlaneId) - return project && t.project?.includes(project.title) + return project && matchesWikilinkTitle(t.project, project.title) }) onTasksReorder(swimlaneId, status, swimlaneTasks) } diff --git a/tdn-desktop/src/components/views/AreaView.tsx b/tdn-desktop/src/components/views/AreaView.tsx index 7691eef0..5dabd8e5 100644 --- a/tdn-desktop/src/components/views/AreaView.tsx +++ b/tdn-desktop/src/components/views/AreaView.tsx @@ -12,6 +12,7 @@ import { useDisplayOrderStore } from '@/store/display-order-store' import type { Task, TaskStatus, Project } from '@/lib/tauri-bindings' import { useTaskDetailStore } from '@/store/task-detail-store' import { useCommandContext } from '@/hooks/use-command-context' +import { matchesWikilinkTitle } from '@/lib/wikilink' import { showTaskContextMenu, showProjectContextMenu } from '@/lib/context-menu' import { useTaskCreationStore } from '@/store/task-creation-store' import { useNavigationStore } from '@/store/navigation-store' @@ -167,7 +168,7 @@ export function AreaView({ areaId }: AreaViewProps) { // Add regular project tasks with stored order applied for (const project of areaProjects) { const rawProjectTasks = tasks.filter(t => - t.project?.includes(project.title) + matchesWikilinkTitle(t.project, project.title) ) const storedOrder = projectTaskOrder?.[project.id] ?? null const orderedProjectTasks = applyStoredOrder(rawProjectTasks, storedOrder) @@ -188,7 +189,9 @@ export function AreaView({ areaId }: AreaViewProps) { // Collect all tasks from projects and loose tasks const allTasks = [...orderedLooseTasks] for (const project of areaProjects) { - const projectTasks = tasks.filter(t => t.project?.includes(project.title)) + const projectTasks = tasks.filter(t => + matchesWikilinkTitle(t.project, project.title) + ) allTasks.push(...projectTasks) } for (const task of allTasks) { diff --git a/tdn-desktop/src/components/views/NoAreaView.tsx b/tdn-desktop/src/components/views/NoAreaView.tsx index 02ddc795..1590d757 100644 --- a/tdn-desktop/src/components/views/NoAreaView.tsx +++ b/tdn-desktop/src/components/views/NoAreaView.tsx @@ -8,6 +8,7 @@ import { useCreateTask, useDeleteTask, } from '@/services/vault' +import { matchesWikilinkTitle } from '@/lib/wikilink' import { useDisplayOrderStore } from '@/store/display-order-store' import type { Task, TaskStatus } from '@/lib/tauri-bindings' import { useTaskDetailStore } from '@/store/task-detail-store' @@ -133,7 +134,7 @@ export function NoAreaView() { // Add regular project tasks with stored order applied for (const project of orphanProjects) { const rawProjectTasks = tasks.filter(t => - t.project?.includes(project.title) + matchesWikilinkTitle(t.project, project.title) ) const storedOrder = projectTaskOrder?.[project.id] ?? null const orderedProjectTasks = applyStoredOrder(rawProjectTasks, storedOrder) @@ -154,7 +155,9 @@ export function NoAreaView() { // Collect all tasks from projects and orphan tasks const allTasks = [...orderedOrphanTasks] for (const project of orphanProjects) { - const projectTasks = tasks.filter(t => t.project?.includes(project.title)) + const projectTasks = tasks.filter(t => + matchesWikilinkTitle(t.project, project.title) + ) allTasks.push(...projectTasks) } for (const task of allTasks) { diff --git a/tdn-desktop/src/components/views/ProjectView.tsx b/tdn-desktop/src/components/views/ProjectView.tsx index 0aa1510d..be2ba90b 100644 --- a/tdn-desktop/src/components/views/ProjectView.tsx +++ b/tdn-desktop/src/components/views/ProjectView.tsx @@ -6,7 +6,7 @@ import { useCreateTask, useDeleteTask, } from '@/services/vault' -import { stripWikilink } from '@/lib/wikilink' +import { matchesWikilinkTitle, stripWikilink } from '@/lib/wikilink' import type { Task, TaskStatus } from '@/lib/tauri-bindings' import { useTaskDetailStore } from '@/store/task-detail-store' import { useCommandContext } from '@/hooks/use-command-context' @@ -70,7 +70,7 @@ export function ProjectView({ projectId }: ProjectViewProps) { // So we match using the project's title, not its ID const projectTasks = React.useMemo(() => { if (!project) return [] - return tasks.filter(t => t.project?.includes(project.title)) + return tasks.filter(t => matchesWikilinkTitle(t.project, project.title)) }, [tasks, project]) // Build tasksByStatus map for kanban view diff --git a/tdn-desktop/src/components/views/TodayView.tsx b/tdn-desktop/src/components/views/TodayView.tsx index 5d93de5e..f4cdcb9a 100644 --- a/tdn-desktop/src/components/views/TodayView.tsx +++ b/tdn-desktop/src/components/views/TodayView.tsx @@ -11,6 +11,7 @@ import { import type { Task } from '@/lib/tauri-bindings' import { useTaskDetailStore } from '@/store/task-detail-store' import { useTaskCreationStore } from '@/store/task-creation-store' +import { matchesWikilinkTitle } from '@/lib/wikilink' import { useTodayOrder, type TodaySectionId } from '@/hooks/use-today-order' import { SectionTaskGroup } from '@/components/tasks/SectionTaskGroup' import { TaskDndContext } from '@/components/tasks/TaskDndContext' @@ -118,16 +119,16 @@ export function TodayView() { ) // Get context name (project/area) for a task - // Note: task.project and task.area are WikiLink format (e.g., "[[My Project]]") - // so we match against the title using includes() const getTaskContextName = React.useCallback( (task: Task): string | undefined => { if (task.project) { - const project = projects.find(p => task.project?.includes(p.title)) + const project = projects.find(p => + matchesWikilinkTitle(task.project, p.title) + ) return project?.title } if (task.area) { - const area = areas.find(a => task.area?.includes(a.title)) + const area = areas.find(a => matchesWikilinkTitle(task.area, a.title)) return area?.title } return undefined diff --git a/tdn-desktop/src/components/views/WeekView.tsx b/tdn-desktop/src/components/views/WeekView.tsx index 4658a715..78ee3216 100644 --- a/tdn-desktop/src/components/views/WeekView.tsx +++ b/tdn-desktop/src/components/views/WeekView.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { startOfWeek, endOfWeek, isWithinInterval, parseISO } from 'date-fns' import { useVaultData, useUpdateTask, useCreateTask } from '@/services/vault' -import { stripWikilink } from '@/lib/wikilink' +import { matchesWikilinkTitle, stripWikilink } from '@/lib/wikilink' import type { Task, TaskStatus } from '@/lib/tauri-bindings' import { useTaskDetailStore } from '@/store/task-detail-store' import { useCommandContext } from '@/hooks/use-command-context' @@ -128,13 +128,17 @@ export function WeekView() { let areaId: string | undefined if (task.project) { - const project = projects.find(p => task.project?.includes(p.title)) + const project = projects.find(p => + matchesWikilinkTitle(task.project, p.title) + ) if (project) { projectName = project.title projectId = project.id // Get area from project if not directly set on task if (project.area) { - const area = areas.find(a => project.area?.includes(a.title)) + const area = areas.find(a => + matchesWikilinkTitle(project.area, a.title) + ) if (area) { areaName = area.title areaId = area.id @@ -145,7 +149,7 @@ export function WeekView() { // Direct area on task overrides project's area if (task.area) { - const area = areas.find(a => task.area?.includes(a.title)) + const area = areas.find(a => matchesWikilinkTitle(task.area, a.title)) if (area) { areaName = area.title areaId = area.id diff --git a/tdn-desktop/src/hooks/use-sidebar-order.ts b/tdn-desktop/src/hooks/use-sidebar-order.ts index 4f360d0a..a168eea4 100644 --- a/tdn-desktop/src/hooks/use-sidebar-order.ts +++ b/tdn-desktop/src/hooks/use-sidebar-order.ts @@ -6,6 +6,7 @@ import { useUpdateProject, } from '@/services/vault' import { useDisplayOrderStore } from '@/store/display-order-store' +import { matchesWikilinkTitle } from '@/lib/wikilink' import type { SidebarOrder } from '@/types/sidebar-order' import { ORPHAN_CONTAINER_ID } from '@/types/sidebar-order' @@ -63,7 +64,9 @@ export function useSidebarOrder() { } const area = areas.find(a => a.id === containerId) if (!area) return [] - return projects.filter(p => p.area?.includes(area.title)).map(p => p.id) + return projects + .filter(p => matchesWikilinkTitle(p.area, area.title)) + .map(p => p.id) })() if (sidebarProjectOrder?.[containerId]) { diff --git a/tdn-desktop/src/lib/wikilink.test.ts b/tdn-desktop/src/lib/wikilink.test.ts index a679afb5..735657c8 100644 --- a/tdn-desktop/src/lib/wikilink.test.ts +++ b/tdn-desktop/src/lib/wikilink.test.ts @@ -3,6 +3,7 @@ import { extractWikilinkTitle, isWikilink, ensureWikilink, + matchesWikilinkTitle, stripWikilink, } from './wikilink' @@ -111,6 +112,32 @@ describe('wikilink', () => { }) }) + describe('matchesWikilinkTitle', () => { + it('matches exact wikilink title', () => { + expect(matchesWikilinkTitle('[[Work]]', 'Work')).toBe(true) + expect(matchesWikilinkTitle('[[My Project]]', 'My Project')).toBe(true) + }) + + it('matches wikilinks with alias or heading', () => { + expect(matchesWikilinkTitle('[[Work|My Job]]', 'Work')).toBe(true) + expect(matchesWikilinkTitle('[[Work#Section]]', 'Work')).toBe(true) + }) + + it('rejects substring matches', () => { + expect(matchesWikilinkTitle('[[Homework]]', 'Work')).toBe(false) + expect(matchesWikilinkTitle('[[Networking]]', 'Net')).toBe(false) + }) + + it('handles null and undefined', () => { + expect(matchesWikilinkTitle(null, 'Work')).toBe(false) + expect(matchesWikilinkTitle(undefined, 'Work')).toBe(false) + }) + + it('returns false for non-wikilinks', () => { + expect(matchesWikilinkTitle('Work', 'Work')).toBe(false) + }) + }) + describe('stripWikilink', () => { it('extracts title from wikilink', () => { expect(stripWikilink('[[Work]]')).toBe('Work') diff --git a/tdn-desktop/src/lib/wikilink.ts b/tdn-desktop/src/lib/wikilink.ts index dd43ef44..785588d2 100644 --- a/tdn-desktop/src/lib/wikilink.ts +++ b/tdn-desktop/src/lib/wikilink.ts @@ -56,6 +56,27 @@ export function ensureWikilink(value: string): string { return isWikilink(trimmed) ? trimmed : `[[${trimmed}]]` } +/** + * Check if a wikilink reference matches a given title. + * Extracts the title from the wikilink and compares it exactly. + * + * This avoids false positives from substring matching — e.g., + * matchesWikilinkTitle('[[Homework]]', 'Work') returns false. + * + * @example + * matchesWikilinkTitle('[[Work]]', 'Work') // true + * matchesWikilinkTitle('[[Work|My Job]]', 'Work') // true + * matchesWikilinkTitle('[[Homework]]', 'Work') // false + * matchesWikilinkTitle(null, 'Work') // false + */ +export function matchesWikilinkTitle( + reference: string | null | undefined, + title: string +): boolean { + if (!reference) return false + return extractWikilinkTitle(reference) === title +} + /** * Strip wikilink brackets from a value if present. * Returns the title if it's a wikilink, otherwise returns the trimmed input. diff --git a/tdn-desktop/src/services/vault/queries.ts b/tdn-desktop/src/services/vault/queries.ts index 640c4524..dd01e537 100644 --- a/tdn-desktop/src/services/vault/queries.ts +++ b/tdn-desktop/src/services/vault/queries.ts @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query' import { logger } from '@/lib/logger' import { filterActiveAreas, filterActiveProjects } from '@/lib/entity-filters' +import { matchesWikilinkTitle } from '@/lib/wikilink' import { commands, type Task, @@ -181,11 +182,10 @@ export function useVaultHelpers() { // Relationship helpers // Note: Wikilinks use TITLES (e.g., "[[Finance]]"), not hash IDs. - // To match, look up the entity by ID, then use its title in includes(). getProjectsByAreaId: (areaId: string) => { const area = areasById.get(areaId) if (!area) return [] - return projects.filter(p => p.area?.includes(area.title)) + return projects.filter(p => matchesWikilinkTitle(p.area, area.title)) }, getOrphanProjects: () => projects.filter(p => !p.area), @@ -193,13 +193,15 @@ export function useVaultHelpers() { getTasksByProjectId: (projectId: string) => { const project = projectsById.get(projectId) if (!project) return [] - return tasks.filter(t => t.project?.includes(project.title)) + return tasks.filter(t => matchesWikilinkTitle(t.project, project.title)) }, getAreaDirectTasks: (areaId: string) => { const area = areasById.get(areaId) if (!area) return [] - return tasks.filter(t => t.area?.includes(area.title) && !t.project) + return tasks.filter( + t => matchesWikilinkTitle(t.area, area.title) && !t.project + ) }, getOrphanTasks: () => tasks.filter(t => !t.project && !t.area), @@ -212,7 +214,9 @@ export function useVaultHelpers() { getProjectCompletion: (projectId: string) => { const project = projectsById.get(projectId) if (!project) return 0 - const projectTasks = tasks.filter(t => t.project?.includes(project.title)) + const projectTasks = tasks.filter(t => + matchesWikilinkTitle(t.project, project.title) + ) if (projectTasks.length === 0) return 0 const completedCount = projectTasks.filter( @@ -225,7 +229,9 @@ export function useVaultHelpers() { getTaskCounts: (projectId: string) => { const project = projectsById.get(projectId) if (!project) return { taskCount: 0, completedTaskCount: 0 } - const projectTasks = tasks.filter(t => t.project?.includes(project.title)) + const projectTasks = tasks.filter(t => + matchesWikilinkTitle(t.project, project.title) + ) const completedCount = projectTasks.filter( t => t.status === 'done' || t.status === 'dropped' ).length