Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions tdn-desktop/src-tauri/src/vault/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task> {
self.tasks.values().cloned().collect()
let mut tasks: Vec<Task> = 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<Project> {
self.projects.values().cloned().collect()
let mut projects: Vec<Project> = 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<Area> {
self.areas.values().cloned().collect()
let mut areas: Vec<Area> = self.areas.values().cloned().collect();
areas.sort_by_cached_key(|a| a.title.to_lowercase());
areas
}

/// Update a task in the index
Expand Down Expand Up @@ -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"]);
}

// ------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion tdn-desktop/src/components/kanban/AreaKanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions tdn-desktop/src/components/views/AreaView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions tdn-desktop/src/components/views/NoAreaView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions tdn-desktop/src/components/views/ProjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions tdn-desktop/src/components/views/TodayView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions tdn-desktop/src/components/views/WeekView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 7 additions & 4 deletions tdn-desktop/src/hooks/use-sidebar-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'],
Expand All @@ -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'])
})
})

Expand Down
43 changes: 28 additions & 15 deletions tdn-desktop/src/hooks/use-sidebar-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -41,30 +42,42 @@ 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])

// 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]
)
Expand Down
27 changes: 27 additions & 0 deletions tdn-desktop/src/lib/wikilink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
extractWikilinkTitle,
isWikilink,
ensureWikilink,
matchesWikilinkTitle,
stripWikilink,
} from './wikilink'

Expand Down Expand Up @@ -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')
Expand Down
Loading