diff --git a/tdn-desktop/src-tauri/src/vault/manager.rs b/tdn-desktop/src-tauri/src/vault/manager.rs index cb9baabe..a308bac2 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 @@ -875,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"]); } // ------------------------------------------------------------------------ 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.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..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' @@ -41,8 +42,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 +57,27 @@ 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 => matchesWikilinkTitle(p.area, 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] ) 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