diff --git a/apps/desktop/src-tauri/src/db/tests.rs b/apps/desktop/src-tauri/src/db/tests.rs index e54d7b859..deb51c739 100644 --- a/apps/desktop/src-tauri/src/db/tests.rs +++ b/apps/desktop/src-tauri/src/db/tests.rs @@ -2,6 +2,7 @@ //! together against a migrated in-memory database, the same shape the commands //! compose at runtime. +use reflect_index_schema::LATEST_SCHEMA_VERSION; use rusqlite::Connection; use serde_json::Value; @@ -68,6 +69,7 @@ fn task(marker_offset: i64, text: &str, checked: bool) -> IndexedTask { marker_offset, text: text.to_string(), raw: format!("[{}] {text}", if checked { "x" } else { " " }), + breadcrumbs: vec![], checked, due_date: None, } @@ -81,7 +83,7 @@ fn migrations_are_valid_and_idempotent() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 16); // applied migrations (0001 through 0016) + assert_eq!(version, LATEST_SCHEMA_VERSION as i64); migrate(&mut conn).expect("re-running to_latest is a no-op"); } @@ -594,13 +596,14 @@ fn apply_note_inserts_tasks_and_replace_clears_them() { let rows = run_query( &conn, - "SELECT marker_offset, text, checked, due_date FROM tasks WHERE note_path = 'notes/a.md' ORDER BY marker_offset", + "SELECT marker_offset, text, breadcrumbs, checked, due_date FROM tasks WHERE note_path = 'notes/a.md' ORDER BY marker_offset", &[], ) .unwrap(); assert_eq!(rows.len(), 2); assert_eq!(rows[0]["marker_offset"], Value::from(4)); assert_eq!(rows[0]["text"], Value::from("buy milk")); + assert_eq!(rows[0]["breadcrumbs"], Value::from("[]")); assert_eq!(rows[0]["checked"], Value::from(0)); assert_eq!(rows[0]["due_date"], Value::from("2026-07-01")); assert_eq!(rows[1]["checked"], Value::from(1)); @@ -612,6 +615,27 @@ fn apply_note_inserts_tasks_and_replace_clears_them() { assert_eq!(after[0]["n"], Value::from(0)); } +#[test] +fn apply_note_serializes_task_breadcrumbs() { + let conn = migrated(); + let mut seeded = note("notes/a.md", "A", vec![]); + let mut with_context = task(4, "ship it", false); + with_context.breadcrumbs = vec!["Project".to_string(), "Phase one".to_string()]; + seeded.tasks = vec![with_context]; + apply_note(&conn, &seeded).unwrap(); + + let rows = run_query( + &conn, + "SELECT breadcrumbs FROM tasks WHERE note_path = 'notes/a.md'", + &[], + ) + .unwrap(); + assert_eq!( + rows[0]["breadcrumbs"], + Value::from("[\"Project\",\"Phase one\"]") + ); +} + #[test] fn open_tasks_read_includes_private_notes_and_excludes_completed() { // The semantics `getOpenTasks` (queries.ts) relies on: open checkboxes joined @@ -801,7 +825,7 @@ fn open_index_at_creates_migrates_and_reopens() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 16); + assert_eq!(version, LATEST_SCHEMA_VERSION as i64); let journal: String = conn .query_row("PRAGMA journal_mode", [], |row| row.get(0)) .unwrap(); diff --git a/apps/desktop/src-tauri/src/db/write.rs b/apps/desktop/src-tauri/src/db/write.rs index 965f48bf6..6e5af1c8d 100644 --- a/apps/desktop/src-tauri/src/db/write.rs +++ b/apps/desktop/src-tauri/src/db/write.rs @@ -89,6 +89,9 @@ pub(super) struct IndexedEmail { pub(super) struct IndexedTask { pub(super) marker_offset: i64, pub(super) text: String, + /// Parent outline/list item text, top-down, displayed in the Tasks view. + #[serde(default)] + pub(super) breadcrumbs: Vec, pub(super) raw: String, pub(super) checked: bool, /// Explicit due date (first `[[YYYY-MM-DD]]` in the item), or None. @@ -178,13 +181,17 @@ pub(super) fn apply_note(conn: &Connection, note: &IndexedNote) -> AppResult<()> } { let mut stmt = conn.prepare_cached( - "INSERT INTO tasks(note_path, marker_offset, text, raw, checked, due_date) VALUES(?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO tasks(note_path, marker_offset, text, breadcrumbs, raw, checked, due_date) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)", )?; for task in ¬e.tasks { + let breadcrumbs = serde_json::to_string(&task.breadcrumbs).map_err(|err| { + crate::error::AppError::io(format!("serialize task breadcrumbs: {err}")) + })?; stmt.execute(params![ note.path, task.marker_offset, task.text, + breadcrumbs, task.raw, i64::from(task.checked), task.due_date diff --git a/apps/desktop/src/components/tasks/task-breadcrumbs.tsx b/apps/desktop/src/components/tasks/task-breadcrumbs.tsx new file mode 100644 index 000000000..0f7e58d9d --- /dev/null +++ b/apps/desktop/src/components/tasks/task-breadcrumbs.tsx @@ -0,0 +1,33 @@ +import type { ReactElement } from 'react' +import { visibleTaskBreadcrumbs } from '@reflect/core' + +interface TaskBreadcrumbsProps { + breadcrumbs: readonly string[] + /** Select every task row sharing this outline context. */ + onSelect: () => void +} + +/** One V1-style outline-context row above the consecutive tasks it labels. */ +export function TaskBreadcrumbs({ + breadcrumbs, + onSelect, +}: TaskBreadcrumbsProps): ReactElement | null { + const visible = visibleTaskBreadcrumbs(breadcrumbs) + if (visible.length === 0) { + return null + } + const label = visible.join(' → ') + + return ( +
  • + +
  • + ) +} diff --git a/apps/desktop/src/components/tasks/task-group-section.tsx b/apps/desktop/src/components/tasks/task-group-section.tsx index 3801dd848..7f8779757 100644 --- a/apps/desktop/src/components/tasks/task-group-section.tsx +++ b/apps/desktop/src/components/tasks/task-group-section.tsx @@ -1,12 +1,13 @@ -import type { MutableRefObject, ReactElement } from 'react' +import { Fragment, type MutableRefObject, type ReactElement } from 'react' import { Plus } from 'lucide-react' -import type { OpenTask, TaskGroup } from '@reflect/core' +import { groupTaskContexts, type OpenTask, type TaskGroup } from '@reflect/core' import { addTargetForGroup, taskGroupHeaderStyle } from '@/lib/tasks/task-group-presentation' import type { InsertTaskTarget } from '@/lib/tasks/task-insert-target' import { taskKey } from '@/lib/tasks/task-identity' import type { TaskSelection } from '@/lib/tasks/use-task-selection' import type { TaskRowEditHandlers } from '@/lib/tasks/use-task-row-handlers' import { cn } from '@/lib/utils' +import { TaskBreadcrumbs } from './task-breadcrumbs' import { TaskRow } from './task-row' interface TaskGroupSectionProps { @@ -48,6 +49,7 @@ export function TaskGroupSection({ const { notePath } = group const { icon, colorClass } = taskGroupHeaderStyle(group) const addTarget = addTargetForGroup(group, today) + const contexts = groupTaskContexts(group.tasks) return (
    @@ -82,24 +84,35 @@ export function TaskGroupSection({ {group.tasks.length === 0 ? (
  • No tasks
  • ) : ( - group.tasks.map((task) => { - const key = taskKey(task) - const selected = selection.isSelected(key) + contexts.map((context) => { + const firstTask = context.tasks[0]! return ( - 1} - onSelect={(event) => selection.clickSelect(key, event)} - onSelectionCheckboxToggle={() => onSelectionCheckboxToggle(task)} - {...editHandlers(task)} - convertControllerRef={convertControllerRef} - onOpen={onOpen} - /> + + selection.select(context.tasks.map(taskKey))} + /> + {context.tasks.map((task) => { + const key = taskKey(task) + const selected = selection.isSelected(key) + return ( + 1} + onSelect={(event) => selection.clickSelect(key, event)} + onSelectionCheckboxToggle={() => onSelectionCheckboxToggle(task)} + {...editHandlers(task)} + convertControllerRef={convertControllerRef} + onOpen={onOpen} + /> + ) + })} + ) }) )} diff --git a/apps/desktop/src/components/tasks/tasks-screen.test.tsx b/apps/desktop/src/components/tasks/tasks-screen.test.tsx index 3e4ba6d44..592398505 100644 --- a/apps/desktop/src/components/tasks/tasks-screen.test.tsx +++ b/apps/desktop/src/components/tasks/tasks-screen.test.tsx @@ -188,6 +188,7 @@ function task(overrides: Partial = {}): OpenTask { raw: `[ ] ${text}`, checked: false, text, + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, @@ -324,6 +325,60 @@ describe('TasksScreen', () => { view.unmount() }) + it('renders one breadcrumb per consecutive task context and selects that context', async () => { + getOpenTasks.mockResolvedValue([ + task({ + notePath: 'notes/p.md', + markerOffset: 2, + text: 'first', + noteTitle: 'Project', + breadcrumbs: ['StartupToolbox', 'Reflections'], + }), + task({ + notePath: 'notes/p.md', + markerOffset: 20, + text: 'second', + noteTitle: 'Project', + breadcrumbs: ['StartupToolbox', 'Reflections'], + }), + task({ + notePath: 'notes/p.md', + markerOffset: 40, + text: 'third', + noteTitle: 'Project', + breadcrumbs: ['StartupToolbox', 'Later'], + }), + ]) + const view = renderScreen() + + const context = await view.findByRole('button', { + name: 'StartupToolbox → Reflections', + }) + expect(view.getAllByText('StartupToolbox → Reflections')).toHaveLength(1) + view.getByText('StartupToolbox → Later') + + await userEvent.click(context) + expect(view.getByRole('button', { name: 'Convert to bullet 2' })).toBeDefined() + view.unmount() + }) + + it('hides a lone generic task breadcrumb', async () => { + getOpenTasks.mockResolvedValue([ + task({ + notePath: 'notes/p.md', + markerOffset: 2, + text: 'project task', + noteTitle: 'Project', + breadcrumbs: ['Tasks:'], + }), + ]) + const view = renderScreen() + + await view.findByText('project task') + expect(view.queryByText('Tasks:')).toBeNull() + view.unmount() + }) + it('opens a task’s source note via the open arrow', async () => { getOpenTasks.mockResolvedValue([ task({ notePath: 'notes/p.md', dailyDate: null, text: 'project task', noteTitle: 'Project' }), diff --git a/apps/desktop/src/dev/dev-index-db.test.ts b/apps/desktop/src/dev/dev-index-db.test.ts index ff7a36a72..bcea9fa0c 100644 --- a/apps/desktop/src/dev/dev-index-db.test.ts +++ b/apps/desktop/src/dev/dev-index-db.test.ts @@ -35,7 +35,16 @@ function sampleNote(overrides: Partial = {}): IndexedNote { aliases: [], emails: [{ email: 'Sample@Example.com', emailKey: 'sample@example.com' }], assets: [], - tasks: [{ markerOffset: 40, text: 'Do the thing', raw: '- [ ] Do the thing', checked: false, dueDate: null }], + tasks: [ + { + markerOffset: 40, + text: 'Do the thing', + breadcrumbs: ['Project'], + raw: '- [ ] Do the thing', + checked: false, + dueDate: null, + }, + ], ...overrides, } } @@ -64,8 +73,8 @@ describe('createDevIndexDb', () => { const tags = db.query('SELECT tag FROM tags WHERE note_path = ?', ['notes/sample.md']) expect(tags).toEqual([{ tag: 'book' }]) - const tasks = db.query('SELECT text, checked FROM tasks', []) - expect(tasks).toEqual([{ text: 'Do the thing', checked: 0 }]) + const tasks = db.query('SELECT text, breadcrumbs, checked FROM tasks', []) + expect(tasks).toEqual([{ text: 'Do the thing', breadcrumbs: '["Project"]', checked: 0 }]) const emails = db.query('SELECT email, email_key FROM note_emails', []) expect(emails).toEqual([ diff --git a/apps/desktop/src/dev/dev-index-db.ts b/apps/desktop/src/dev/dev-index-db.ts index 6251d4c03..66407813f 100644 --- a/apps/desktop/src/dev/dev-index-db.ts +++ b/apps/desktop/src/dev/dev-index-db.ts @@ -168,8 +168,16 @@ export async function createDevIndexDb(): Promise { for (const task of note.tasks) { run( db, - 'INSERT INTO tasks(note_path, marker_offset, text, raw, checked, due_date) VALUES(?, ?, ?, ?, ?, ?)', - [note.path, task.markerOffset, task.text, task.raw, task.checked, task.dueDate], + 'INSERT INTO tasks(note_path, marker_offset, text, breadcrumbs, raw, checked, due_date) VALUES(?, ?, ?, ?, ?, ?, ?)', + [ + note.path, + task.markerOffset, + task.text, + JSON.stringify(task.breadcrumbs), + task.raw, + task.checked, + task.dueDate, + ], ) } const searchBody = note.assetText === '' ? note.text : `${note.text}\n${note.assetText}` diff --git a/apps/desktop/src/lib/selection/use-list-selection.test.ts b/apps/desktop/src/lib/selection/use-list-selection.test.ts index a3d97dcdd..92e01eff9 100644 --- a/apps/desktop/src/lib/selection/use-list-selection.test.ts +++ b/apps/desktop/src/lib/selection/use-list-selection.test.ts @@ -50,6 +50,18 @@ describe('useListSelection', () => { expect(result.current.activeKey()).toBe('c') }) + it('programmatically selects visible keys in render order', () => { + const { result } = renderHook(() => useListSelection(KEYS)) + + act(() => result.current.select(['d', 'missing', 'b'])) + expect([...result.current.selected]).toEqual(['b', 'd']) + expect(result.current.activeKey()).toBe('d') + + act(() => result.current.select([])) + expect(result.current.selectedCount).toBe(0) + expect(result.current.activeKey()).toBeNull() + }) + it('prunes keys that leave the visible order', () => { const { result, rerender } = renderHook(({ keys }) => useListSelection(keys), { initialProps: { keys: KEYS }, diff --git a/apps/desktop/src/lib/selection/use-list-selection.ts b/apps/desktop/src/lib/selection/use-list-selection.ts index d4b393533..cbec890e4 100644 --- a/apps/desktop/src/lib/selection/use-list-selection.ts +++ b/apps/desktop/src/lib/selection/use-list-selection.ts @@ -25,6 +25,8 @@ export interface ListSelection { isSoleSelected: (key: string) => boolean /** Apply a row click, honoring ⌘/Ctrl (toggle) and Shift (range) modifiers. */ clickSelect: (key: string, event: Pick) => void + /** Replace the selection with these visible keys, normalized to render order. */ + select: (keys: readonly string[]) => void selectAll: () => void clear: () => void /** ↑/↓: move a single selection by one in the flat order. */ @@ -115,6 +117,14 @@ export function useListSelection(orderedKeys: readonly string[]): ListSelection [rangeBetween], ) + const select = useCallback((keys: readonly string[]) => { + const requested = new Set(keys) + const visible = orderRef.current.filter((key) => requested.has(key)) + setSelected(new Set(visible)) + anchorRef.current = visible[0] ?? null + cursorRef.current = visible.at(-1) ?? null + }, []) + const selectAll = useCallback(() => { const order = orderRef.current if (order.length === 0) { @@ -175,6 +185,7 @@ export function useListSelection(orderedKeys: readonly string[]): ListSelection isSelected: (key) => selected.has(key), isSoleSelected: (key) => selected.size === 1 && selected.has(key), clickSelect, + select, selectAll, clear, move, diff --git a/apps/desktop/src/lib/tasks/recently-completed.test.ts b/apps/desktop/src/lib/tasks/recently-completed.test.ts index 4e763b33d..d47d8bde7 100644 --- a/apps/desktop/src/lib/tasks/recently-completed.test.ts +++ b/apps/desktop/src/lib/tasks/recently-completed.test.ts @@ -18,6 +18,7 @@ function task(over: Partial = {}): OpenTask { raw: '[ ] do it', checked: false, text: 'do it', + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, diff --git a/apps/desktop/src/lib/tasks/task-cache.test.ts b/apps/desktop/src/lib/tasks/task-cache.test.ts index 06a271123..33e7d8445 100644 --- a/apps/desktop/src/lib/tasks/task-cache.test.ts +++ b/apps/desktop/src/lib/tasks/task-cache.test.ts @@ -17,6 +17,7 @@ function task(overrides: Partial = {}): OpenTask { raw: `[ ] ${text}`, checked: false, text, + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, diff --git a/apps/desktop/src/lib/tasks/task-insert-target.ts b/apps/desktop/src/lib/tasks/task-insert-target.ts index 230c42319..e5a160d79 100644 --- a/apps/desktop/src/lib/tasks/task-insert-target.ts +++ b/apps/desktop/src/lib/tasks/task-insert-target.ts @@ -20,6 +20,7 @@ export function insertedTaskRow(target: InsertTaskTarget, markerOffset: number): raw: '[ ] ', checked: false, text: '', + breadcrumbs: [], noteTitle: target.noteTitle, dueDate: null, dailyDate: target.dailyDate, diff --git a/apps/desktop/src/lib/tasks/task-navigation.test.ts b/apps/desktop/src/lib/tasks/task-navigation.test.ts index 68bf78b19..7c4f3c00f 100644 --- a/apps/desktop/src/lib/tasks/task-navigation.test.ts +++ b/apps/desktop/src/lib/tasks/task-navigation.test.ts @@ -10,6 +10,7 @@ function task(over: Partial = {}): OpenTask { raw: '[ ] x', checked: false, text: 'x', + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, diff --git a/apps/desktop/src/lib/tasks/task-visibility.test.ts b/apps/desktop/src/lib/tasks/task-visibility.test.ts index 06a556e38..a820c910a 100644 --- a/apps/desktop/src/lib/tasks/task-visibility.test.ts +++ b/apps/desktop/src/lib/tasks/task-visibility.test.ts @@ -22,6 +22,7 @@ function task(overrides: Partial = {}): OpenTask { raw: `[ ] ${text}`, checked: false, text, + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, @@ -124,6 +125,38 @@ describe('composeVisibleTaskGroups', () => { const rows = groups.flatMap((group) => group.tasks) expect(rows.map((row) => row.text)).toEqual(['buy milk']) }) + + it('filters by breadcrumb context', () => { + const groups = composeVisibleTaskGroups({ + open: [ + task({ text: 'ship', markerOffset: 0, breadcrumbs: ['StartupToolbox', 'Reflections'] }), + task({ text: 'buy milk', markerOffset: 10, breadcrumbs: ['Personal'] }), + ], + completed: undefined, + recentlyCompleted: [], + filters: ALL_ON, + needle: 'startup', + today: TODAY, + }) + const rows = groups.flatMap((group) => group.tasks) + expect(rows.map((row) => row.text)).toEqual(['ship']) + }) + + it('filters by source-note title', () => { + const groups = composeVisibleTaskGroups({ + open: [ + task({ text: 'ship it', markerOffset: 0, noteTitle: 'Desktop launch' }), + task({ text: 'buy milk', markerOffset: 10, noteTitle: 'Home' }), + ], + completed: undefined, + recentlyCompleted: [], + filters: ALL_ON, + needle: 'desktop', + today: TODAY, + }) + const rows = groups.flatMap((group) => group.tasks) + expect(rows.map((row) => row.text)).toEqual(['ship it']) + }) }) describe('visibleGroups', () => { diff --git a/apps/desktop/src/lib/tasks/task-visibility.ts b/apps/desktop/src/lib/tasks/task-visibility.ts index cbfba9312..5c894f1e0 100644 --- a/apps/desktop/src/lib/tasks/task-visibility.ts +++ b/apps/desktop/src/lib/tasks/task-visibility.ts @@ -32,6 +32,12 @@ export interface TaskListSources { readonly today: string } +function taskMatchesNeedle(task: OpenTask, needle: string): boolean { + return [task.text, task.noteTitle, ...task.breadcrumbs].some((text) => + text.toLowerCase().includes(needle), + ) +} + /** * The Tasks list every surface renders (Plan 18): open rows merged with the * struck "completed" rows, searched, grouped ({@link groupTasks}) and narrowed to @@ -71,6 +77,6 @@ export function composeVisibleTaskGroups({ : recentlyCompleted const completedKeys = new Set(completedRows.map(taskKey)) const all = [...open.filter((task) => !completedKeys.has(taskKey(task))), ...completedRows] - const matched = needle ? all.filter((task) => task.text.toLowerCase().includes(needle)) : all + const matched = needle ? all.filter((task) => taskMatchesNeedle(task, needle)) : all return visibleGroups(groupTasks(matched, today), filters) } diff --git a/apps/desktop/src/lib/tasks/use-task-keyboard.test.ts b/apps/desktop/src/lib/tasks/use-task-keyboard.test.ts index 30507d85c..d50da4a0f 100644 --- a/apps/desktop/src/lib/tasks/use-task-keyboard.test.ts +++ b/apps/desktop/src/lib/tasks/use-task-keyboard.test.ts @@ -13,6 +13,7 @@ function task(over: Partial = {}): OpenTask { raw: '[ ] do it', checked: false, text: 'do it', + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, @@ -30,6 +31,7 @@ function makeSelection(over: Partial = {}): TaskSelection { isSelected: () => false, isSoleSelected: () => false, clickSelect: vi.fn(), + select: vi.fn(), selectAll: vi.fn(), clear: vi.fn(), move: vi.fn(), diff --git a/apps/desktop/src/lib/tasks/use-task-selection.ts b/apps/desktop/src/lib/tasks/use-task-selection.ts index 32e5b1525..43030c411 100644 --- a/apps/desktop/src/lib/tasks/use-task-selection.ts +++ b/apps/desktop/src/lib/tasks/use-task-selection.ts @@ -8,7 +8,8 @@ import { useListSelection, type ListSelection } from '@/lib/selection/use-list-s * view code and its tests read in task terms. * * `isSoleSelected` is the state that mounts a task row's inline editor; - * `activeKey()` is the row Return-to-add targets. + * `activeKey()` is the row Return-to-add targets. Programmatic selection is + * used by a task-context breadcrumb to select the rows it labels. */ export type TaskSelection = ListSelection diff --git a/apps/desktop/src/mobile/screens/tasks.test.tsx b/apps/desktop/src/mobile/screens/tasks.test.tsx index 9b1d0f5d3..d60df45f3 100644 --- a/apps/desktop/src/mobile/screens/tasks.test.tsx +++ b/apps/desktop/src/mobile/screens/tasks.test.tsx @@ -182,6 +182,7 @@ function task(overrides: Partial = {}): OpenTask { raw: `[ ] ${text}`, checked: false, text, + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, diff --git a/apps/desktop/src/mobile/use-task-sheet-finalizer.test.ts b/apps/desktop/src/mobile/use-task-sheet-finalizer.test.ts index b8514edba..d9d2000ca 100644 --- a/apps/desktop/src/mobile/use-task-sheet-finalizer.test.ts +++ b/apps/desktop/src/mobile/use-task-sheet-finalizer.test.ts @@ -21,6 +21,7 @@ function task(overrides: Partial = {}): OpenTask { raw: `[ ] ${text}`, checked: false, text, + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, diff --git a/crates/index-schema/migrations/0017_task_breadcrumbs.sql b/crates/index-schema/migrations/0017_task_breadcrumbs.sql new file mode 100644 index 000000000..8cbc20d0b --- /dev/null +++ b/crates/index-schema/migrations/0017_task_breadcrumbs.sql @@ -0,0 +1,4 @@ +-- Parent outline/list item text for a task, encoded as a JSON string array. +-- Rebuildable projection data: the TS projection version bump reindexes notes +-- so existing rows get their real breadcrumbs instead of this empty default. +ALTER TABLE tasks ADD COLUMN breadcrumbs TEXT NOT NULL DEFAULT '[]'; diff --git a/crates/index-schema/src/lib.rs b/crates/index-schema/src/lib.rs index 2aedfad98..fb202288a 100644 --- a/crates/index-schema/src/lib.rs +++ b/crates/index-schema/src/lib.rs @@ -23,7 +23,7 @@ pub const INDEX_FILE: &str = "index.sqlite"; /// `user_version` after every migration has run. Read-only consumers compare /// this against `PRAGMA user_version` to detect an index written by a newer /// (or older) app than they were built for. -pub const LATEST_SCHEMA_VERSION: usize = 16; +pub const LATEST_SCHEMA_VERSION: usize = 17; /// The `index_meta` key holding the TS-owned projection version (the rows' /// derivation version, distinct from the schema version above). @@ -59,6 +59,7 @@ mod schema { M::up(include_str!("../migrations/0014_note_kind.sql")), M::up(include_str!("../migrations/0015_note_kind_invariant.sql")), M::up(include_str!("../migrations/0016_note_emails.sql")), + M::up(include_str!("../migrations/0017_task_breadcrumbs.sql")), ]) }); diff --git a/packages/core/src/exports/sync-markdown-indexing.ts b/packages/core/src/exports/sync-markdown-indexing.ts index 90694aab5..ae31c875c 100644 --- a/packages/core/src/exports/sync-markdown-indexing.ts +++ b/packages/core/src/exports/sync-markdown-indexing.ts @@ -158,8 +158,10 @@ export { getNotesByTag, getOpenTasks, getCompletedTasks, + groupTaskContexts, groupTasks, taskDateBucket, + visibleTaskBreadcrumbs, listDailyNotes, listNotes, listNoteTags, @@ -196,6 +198,7 @@ export { type DuplicateIdGroup, type NoteRow, type OpenTask, + type TaskContext, type TaskGroup, type TaskGroupKind, type NoteListEntry, diff --git a/packages/core/src/indexing/group-tasks.test.ts b/packages/core/src/indexing/group-tasks.test.ts index 59ade0c6c..6de436b83 100644 --- a/packages/core/src/indexing/group-tasks.test.ts +++ b/packages/core/src/indexing/group-tasks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { groupTasks, taskDateBucket } from './group-tasks' +import { groupTaskContexts, groupTasks, taskDateBucket, visibleTaskBreadcrumbs } from './group-tasks' import type { OpenTask } from './queries' const TODAY = '2026-06-14' @@ -14,6 +14,7 @@ function task(overrides: Partial = {}): OpenTask { raw: '[ ] do it', checked: false, text: 'do it', + breadcrumbs: [], noteTitle: 'N', dueDate: null, dailyDate: null, @@ -24,6 +25,49 @@ function task(overrides: Partial = {}): OpenTask { } } +describe('visibleTaskBreadcrumbs', () => { + it('trims empty breadcrumb entries', () => { + expect(visibleTaskBreadcrumbs(['', ' Project ', ' '])).toEqual(['Project']) + }) + + it('hides common single task headings', () => { + for (const heading of ['Task', 'Tasks:', 'todo', 'TODOs', 'To Do', "To Do's: "]) { + expect(visibleTaskBreadcrumbs([heading])).toEqual([]) + } + }) + + it('keeps multi-part breadcrumbs even when one part is common', () => { + expect(visibleTaskBreadcrumbs(['Tasks', 'Project'])).toEqual(['Tasks', 'Project']) + }) +}) + +describe('groupTaskContexts', () => { + it('groups only consecutive tasks with the same breadcrumbs', () => { + const tasks = [ + task({ markerOffset: 1, breadcrumbs: ['Project', 'Phase one'] }), + task({ markerOffset: 2, breadcrumbs: ['Project', 'Phase one'] }), + task({ markerOffset: 3, breadcrumbs: ['Project', 'Phase two'] }), + task({ markerOffset: 4, breadcrumbs: ['Project', 'Phase one'] }), + ] + + const contexts = groupTaskContexts(tasks) + expect(contexts.map((context) => context.tasks.map((entry) => entry.markerOffset))).toEqual([ + [1, 2], + [3], + [4], + ]) + expect(contexts.map((context) => context.breadcrumbs)).toEqual([ + ['Project', 'Phase one'], + ['Project', 'Phase two'], + ['Project', 'Phase one'], + ]) + }) + + it('returns no contexts for no tasks', () => { + expect(groupTaskContexts([])).toEqual([]) + }) +}) + describe('taskDateBucket', () => { it('classifies one task by the same rules as the grouping', () => { // Undated → grouped under its note. diff --git a/packages/core/src/indexing/group-tasks.ts b/packages/core/src/indexing/group-tasks.ts index ad655ee4e..b83a7f869 100644 --- a/packages/core/src/indexing/group-tasks.ts +++ b/packages/core/src/indexing/group-tasks.ts @@ -30,6 +30,47 @@ export interface TaskGroup { tasks: OpenTask[] } +const PUNCTUATION_RE = /[\p{P}\p{S}]/gu + +function normalizedBreadcrumb(text: string): string { + return text.replace(/\s+/g, '').replace(PUNCTUATION_RE, '') +} + +/** Trim breadcrumb labels and hide a lone generic Tasks/Todo parent. */ +export function visibleTaskBreadcrumbs(breadcrumbs: readonly string[]): string[] { + const visible = breadcrumbs.map((text) => text.trim()).filter((text) => text.length > 0) + if (visible.length !== 1) { + return visible + } + return /^(?:task|todo)s?$/i.test(normalizedBreadcrumb(visible[0]!)) ? [] : visible +} + +/** One consecutive run of task rows sharing the same parent outline labels. */ +export interface TaskContext { + readonly breadcrumbs: readonly string[] + readonly tasks: readonly OpenTask[] +} + +function haveSameBreadcrumbs(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((part, index) => part === right[index]) +} + +/** Group consecutive task rows that share the same parent outline context. */ +export function groupTaskContexts(tasks: readonly OpenTask[]): TaskContext[] { + const contexts: { breadcrumbs: readonly string[]; tasks: OpenTask[] }[] = [] + + for (const task of tasks) { + const previous = contexts.at(-1) + if (previous !== undefined && haveSameBreadcrumbs(previous.breadcrumbs, task.breadcrumbs)) { + previous.tasks.push(task) + } else { + contexts.push({ breadcrumbs: task.breadcrumbs, tasks: [task] }) + } + } + + return contexts +} + /** The date a task is bucketed by: its explicit due date, else its note's date. */ function effectiveDate(task: OpenTask): string | null { return task.dueDate ?? task.dailyDate diff --git a/packages/core/src/indexing/index.ts b/packages/core/src/indexing/index.ts index 4e66769e1..eb98d05fb 100644 --- a/packages/core/src/indexing/index.ts +++ b/packages/core/src/indexing/index.ts @@ -92,7 +92,15 @@ export { type TagSuggestion, } from './queries' export { resolveNoteTarget } from './resolve-target' -export { groupTasks, taskDateBucket, type TaskGroup, type TaskGroupKind } from './group-tasks' +export { + groupTaskContexts, + groupTasks, + taskDateBucket, + visibleTaskBreadcrumbs, + type TaskContext, + type TaskGroup, + type TaskGroupKind, +} from './group-tasks' export { listNotes, listNoteTags, diff --git a/packages/core/src/indexing/indexed-note.test.ts b/packages/core/src/indexing/indexed-note.test.ts index 471e35063..4ac0e18b9 100644 --- a/packages/core/src/indexing/indexed-note.test.ts +++ b/packages/core/src/indexing/indexed-note.test.ts @@ -3,8 +3,8 @@ import { gistBodyHash, parseNote } from '../markdown' import { buildIndexedNote, indexedNoteSchema, PROJECTION_VERSION } from './indexed-note' describe('buildIndexedNote', () => { - it('carries the projection version that backfills the derived subject-alias rows', () => { - expect(PROJECTION_VERSION).toBe(14) + it('carries the projection version that backfills task breadcrumbs', () => { + expect(PROJECTION_VERSION).toBe(15) }) it('flattens a parsed note into the index payload', () => { @@ -219,8 +219,22 @@ describe('buildIndexedNote', () => { source, }) expect(indexed.tasks).toEqual([ - { markerOffset: source.indexOf('[ ]'), text: 'buy milk', raw: '[ ] buy milk', checked: false, dueDate: null }, - { markerOffset: source.indexOf('[x] call'), text: 'call mum', raw: '[x] call mum', checked: true, dueDate: null }, + { + markerOffset: source.indexOf('[ ]'), + text: 'buy milk', + breadcrumbs: [], + raw: '[ ] buy milk', + checked: false, + dueDate: null, + }, + { + markerOffset: source.indexOf('[x] call'), + text: 'call mum', + breadcrumbs: [], + raw: '[x] call mum', + checked: true, + dueDate: null, + }, ]) }) diff --git a/packages/core/src/indexing/indexed-note.ts b/packages/core/src/indexing/indexed-note.ts index e4207cc60..249ec9287 100644 --- a/packages/core/src/indexing/indexed-note.ts +++ b/packages/core/src/indexing/indexed-note.ts @@ -63,8 +63,10 @@ import { previewSnippet } from './snippet' * 14 — v1 subject aliases (`//` segments of the title) folded into the * `aliases` projection: existing v1-style titles carry no derived alias rows * until reprojected, so the bump backfills them. + * 15 — task parent outline/list breadcrumbs: existing task rows carry empty + * breadcrumbs until reprojected. */ -export const PROJECTION_VERSION = 14 +export const PROJECTION_VERSION = 15 export const indexedLinkSchema = z.object({ kind: z.enum(['wiki', 'md']), @@ -104,6 +106,8 @@ export const indexedTaskSchema = z.object({ markerOffset: z.number(), /** Display/search text of the task's marker line, markdown stripped. */ text: z.string(), + /** Parent outline/list item text, top-down, displayed in the Tasks view. */ + breadcrumbs: z.array(z.string()).readonly(), /** The marker line verbatim — the surgical write-back's staleness guard. */ raw: z.string(), checked: z.boolean(), @@ -237,6 +241,7 @@ export function buildIndexedNote( tasks: parsed.tasks.map((task) => ({ markerOffset: task.markerOffset, text: task.text, + breadcrumbs: task.breadcrumbs, raw: task.raw, checked: task.checked, dueDate: task.dueDate, diff --git a/packages/core/src/indexing/queries-tasks.ts b/packages/core/src/indexing/queries-tasks.ts index 1bbc046eb..3460b1460 100644 --- a/packages/core/src/indexing/queries-tasks.ts +++ b/packages/core/src/indexing/queries-tasks.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import type { TaskMarker } from '../markdown' import { db } from './db' @@ -11,6 +12,8 @@ export interface OpenTask extends TaskMarker { checked: boolean /** Display text, markdown stripped. */ text: string + /** Parent outline/list item text, top-down, displayed above the task row. */ + breadcrumbs: readonly string[] noteTitle: string /** The task's explicit `[[YYYY-MM-DD]]` due date, or null. */ dueDate: string | null @@ -22,6 +25,8 @@ export interface OpenTask extends TaskMarker { updatedAt: number } +const taskBreadcrumbsSchema = z.array(z.string()).readonly() + function taskRowsQuery() { return db .selectFrom('tasks') @@ -32,6 +37,7 @@ function taskRowsQuery() { 'tasks.markerOffset', 'tasks.raw', 'tasks.text', + 'tasks.breadcrumbs', 'tasks.checked', 'tasks.dueDate', 'notes.title as noteTitle', @@ -45,8 +51,10 @@ function taskRowsQuery() { function toTaskRow(row: { checked: number isPinned: number -}): { checked: boolean; isPinned: boolean } { - return { ...row, checked: row.checked !== 0, isPinned: row.isPinned !== 0 } + breadcrumbs: string +}): { checked: boolean; isPinned: boolean; breadcrumbs: readonly string[] } { + const breadcrumbs = taskBreadcrumbsSchema.parse(JSON.parse(row.breadcrumbs)) + return { ...row, checked: row.checked !== 0, isPinned: row.isPinned !== 0, breadcrumbs } } /** diff --git a/packages/core/src/indexing/queries.test.ts b/packages/core/src/indexing/queries.test.ts index 963f3d28f..edd2ab1c9 100644 --- a/packages/core/src/indexing/queries.test.ts +++ b/packages/core/src/indexing/queries.test.ts @@ -377,6 +377,42 @@ describe('suggestWikiTargets', () => { }) describe('getOpenTasks', () => { + it('parses task breadcrumbs and normalizes boolean note context', async () => { + mockInvoke.mockResolvedValue([ + { + note_path: 'notes/project.md', + marker_offset: 12, + raw: '[ ] ship it', + text: 'ship it', + breadcrumbs: '["StartupToolbox","Reflections"]', + checked: 0, + due_date: null, + note_title: 'Project', + daily_date: null, + is_pinned: 1, + pinned_order: 2, + updated_at: 123, + }, + ]) + + await expect(getOpenTasks()).resolves.toEqual([ + { + notePath: 'notes/project.md', + markerOffset: 12, + raw: '[ ] ship it', + text: 'ship it', + breadcrumbs: ['StartupToolbox', 'Reflections'], + checked: false, + dueDate: null, + noteTitle: 'Project', + dailyDate: null, + isPinned: true, + pinnedOrder: 2, + updatedAt: 123, + }, + ]) + }) + it('never surfaces template checkboxes — boilerplate, not real tasks', async () => { mockInvoke.mockResolvedValue([]) diff --git a/packages/core/src/markdown/extract.test.ts b/packages/core/src/markdown/extract.test.ts index 71884884f..ff369ca20 100644 --- a/packages/core/src/markdown/extract.test.ts +++ b/packages/core/src/markdown/extract.test.ts @@ -146,15 +146,36 @@ describe('parseNote — tasks', () => { it('extracts open and checked round task checkboxes with text, raw, marker offset', () => { const note = parse('+ [ ] buy milk\n+ [x] call mum\n') expect(note.tasks).toEqual([ - { text: 'buy milk', raw: '[ ] buy milk', checked: false, markerOffset: 2, dueDate: null }, - { text: 'call mum', raw: '[x] call mum', checked: true, markerOffset: 17, dueDate: null }, + { + text: 'buy milk', + breadcrumbs: [], + raw: '[ ] buy milk', + checked: false, + markerOffset: 2, + dueDate: null, + }, + { + text: 'call mum', + breadcrumbs: [], + raw: '[x] call mum', + checked: true, + markerOffset: 17, + dueDate: null, + }, ]) }) it('treats an uppercase [X] marker as checked', () => { const note = parse('+ [X] done\n') expect(note.tasks).toEqual([ - { text: 'done', raw: '[X] done', checked: true, markerOffset: 2, dueDate: null }, + { + text: 'done', + breadcrumbs: [], + raw: '[X] done', + checked: true, + markerOffset: 2, + dueDate: null, + }, ]) }) @@ -184,10 +205,40 @@ describe('parseNote — tasks', () => { ]) }) + it('captures parent outline items as task breadcrumbs', () => { + const note = parse('+ Project [[Alpha]]\n + **Phase one**\n + [ ] ship it\n') + expect(note.tasks).toEqual([ + expect.objectContaining({ + text: 'ship it', + breadcrumbs: ['Project Alpha', 'Phase one'], + }), + ]) + }) + + it('keeps wrapped parent text in a single breadcrumb label', () => { + const note = parse('+ **Project Alpha**\n continues on this line\n + [ ] ship it\n') + expect(note.tasks[0]?.breadcrumbs).toEqual(['Project Alpha continues on this line']) + }) + + it('uses parent task rows as breadcrumbs for nested subtasks', () => { + const note = parse('+ [ ] parent task\n + [x] child task\n') + expect(note.tasks.map((task) => ({ text: task.text, breadcrumbs: task.breadcrumbs }))).toEqual([ + { text: 'parent task', breadcrumbs: [] }, + { text: 'child task', breadcrumbs: ['parent task'] }, + ]) + }) + it('ignores checkboxes inside fenced code', () => { const note = parse('+ [ ] real\n\n```\n+ [ ] not a task\n```\n') expect(note.tasks).toEqual([ - { text: 'real', raw: '[ ] real', checked: false, markerOffset: 2, dueDate: null }, + { + text: 'real', + breadcrumbs: [], + raw: '[ ] real', + checked: false, + markerOffset: 2, + dueDate: null, + }, ]) }) @@ -215,4 +266,12 @@ describe('parseNote — tasks', () => { const note = parse('+ [ ] no date here\n+ [ ] dated [[2026-07-01]]\n') expect(note.tasks.map((task) => task.dueDate)).toEqual([null, '2026-07-01']) }) + + it('does not borrow a due-date link from a nested child task', () => { + const note = parse('+ [ ] parent\n + [ ] child [[2026-07-01]]\n') + expect(note.tasks.map((task) => ({ text: task.text, dueDate: task.dueDate }))).toEqual([ + { text: 'parent', dueDate: null }, + { text: 'child 2026-07-01', dueDate: '2026-07-01' }, + ]) + }) }) diff --git a/packages/core/src/markdown/extract.ts b/packages/core/src/markdown/extract.ts index 807f1e94b..594cf1b48 100644 --- a/packages/core/src/markdown/extract.ts +++ b/packages/core/src/markdown/extract.ts @@ -1,3 +1,4 @@ +import type { SyntaxNode } from '@lezer/common' import { dateFromDailyPath, isDaily } from '../graph/paths' import { parseFrontmatter, splitFrontmatter } from './frontmatter' import { parseBody } from './grammar' @@ -188,6 +189,62 @@ function hasRoundTaskListMarker(body: string, markerStart: number): boolean { return /^[\t ]*\+[\t ]+$/.test(body.slice(lineStart, markerStart)) } +function lineEndAfter(body: string, from: number): number { + const newline = body.indexOf('\n', from) + return newline === -1 ? body.length : newline +} + +function listItemLeadTextblock(item: SyntaxNode): SyntaxNode | null { + for (let child = item.firstChild; child !== null; child = child.nextSibling) { + if (child.name === 'ListMark') { + continue + } + return child.name === 'Paragraph' || child.name === 'Task' ? child : null + } + return null +} + +function listItemBreadcrumbLabel( + body: string, + item: SyntaxNode, + cuts: Span[], + literalRanges: Span[], +): string | null { + const textblock = listItemLeadTextblock(item) + if (textblock === null) { + return null + } + const text = plainTextOfRange(body, textblock.from, textblock.to, cuts, literalRanges) + return text === '' ? null : text +} + +function taskBreadcrumbs( + body: string, + taskNode: SyntaxNode, + cuts: Span[], + literalRanges: Span[], +): string[] { + const ownItem = taskNode.parent + if (ownItem?.name !== 'ListItem') { + return [] + } + + const breadcrumbs: string[] = [] + let ancestor = ownItem.parent + + while (ancestor !== null && ancestor !== undefined) { + if (ancestor.name === 'ListItem') { + const text = listItemBreadcrumbLabel(body, ancestor, cuts, literalRanges) + if (text !== null) { + breadcrumbs.push(text) + } + } + ancestor = ancestor.parent + } + + return breadcrumbs.reverse() +} + /** * Resolve a `Task` Lezer node (the marker starts at `from`) into a * {@link ParsedTask}, or `null` when the marker shape isn't Reflect's task @@ -196,13 +253,13 @@ function hasRoundTaskListMarker(body: string, markerStart: number): boolean { */ function readTask( body: string, - range: Span, + taskNode: SyntaxNode, bodyOffset: number, cuts: Span[], literalRanges: Span[], wikiLinks: WikiLink[], ): ParsedTask | null { - const { from, to } = range + const { from, to } = taskNode if (!hasRoundTaskListMarker(body, from)) { return null } @@ -210,11 +267,11 @@ function readTask( if (marker === null) { return null } - const newline = body.indexOf('\n', from) - const lineEnd = newline === -1 ? body.length : newline + const lineEnd = lineEndAfter(body, from) const markerOffset = from + bodyOffset return { text: plainTextOfRange(body, from, lineEnd, cuts, literalRanges), + breadcrumbs: taskBreadcrumbs(body, taskNode, cuts, literalRanges), raw: body.slice(from, lineEnd), checked: marker.checked, markerOffset, @@ -313,7 +370,7 @@ export function parseNote(input: { path: string; source: string }): ParsedNote { const cuts: Span[] = [] // body coords — syntax to drop from plain text const tagExcluded: Span[] = [] // body coords — regions that don't yield tags const literalPlainText: Span[] = [] // body coords — regions that render backslashes literally - const taskRanges: Span[] = [] // body coords — `Task` node spans, resolved after the walk + const taskNodes: SyntaxNode[] = [] // body coords — `Task` nodes, resolved after the walk tree.iterate({ enter: (node) => { @@ -327,7 +384,7 @@ export function parseNote(input: { path: string; source: string }): ParsedNote { // needs to strip its text — and the `[[date]]` due-date link inside it — // aren't collected until their own `enter`. The node span bounds the // due-date search to this task. - taskRanges.push({ from, to }) + taskNodes.push(node.node) } if (isTagExcludedNode(name)) { tagExcluded.push({ from, to }) @@ -369,8 +426,8 @@ export function parseNote(input: { path: string; source: string }): ParsedNote { collectTags(body, tagExcluded, tags) const tasks: ParsedTask[] = [] - for (const range of taskRanges) { - const task = readTask(body, range, bodyOffset, cuts, literalPlainText, wikiLinks) + for (const taskNode of taskNodes) { + const task = readTask(body, taskNode, bodyOffset, cuts, literalPlainText, wikiLinks) if (task) { tasks.push(task) } diff --git a/packages/core/src/markdown/model.ts b/packages/core/src/markdown/model.ts index 8f9f0cb29..ea48f60f7 100644 --- a/packages/core/src/markdown/model.ts +++ b/packages/core/src/markdown/model.ts @@ -187,6 +187,8 @@ export interface TaskMarker { export interface ParsedTask extends TaskMarker { /** Inline text of the item's marker line, markdown stripped, for display + search. */ text: string + /** Parent outline/list item text, top-down, for the Tasks view breadcrumb. */ + breadcrumbs: readonly string[] /** `[x]`/`[X]` → true, `[ ]` → false. */ checked: boolean /** @@ -202,8 +204,9 @@ export interface ParsedTask extends TaskMarker { /** Version of the extraction contract; bump on breaking shape changes. * 1 — Plan 03 baseline · 2 — `tasks: ParsedTask[]` (with `dueDate`) added (Plan 18) · * 3 — tasks limited to round Meowdown `+ [ ]` / `+ [x]` syntax; square checklist - * checkboxes are excluded. */ -export const PARSED_NOTE_VERSION = 3 + * checkboxes are excluded. + * 4 — task rows carry parent outline/list breadcrumbs. */ +export const PARSED_NOTE_VERSION = 4 /** The full parse of one note — the stable contract downstream plans depend on. */ export interface ParsedNote { diff --git a/packages/db/src/schema.gen.ts b/packages/db/src/schema.gen.ts index 87e85d58a..af109d024 100644 --- a/packages/db/src/schema.gen.ts +++ b/packages/db/src/schema.gen.ts @@ -122,6 +122,7 @@ export interface Tags { } export interface Tasks { + breadcrumbs: Generated; checked: number; dueDate: string | null; markerOffset: number;