diff --git a/docs/tasks-todo/task-x-move-incomplete-tasks-to-today.md b/docs/tasks-todo/task-x-move-incomplete-tasks-to-today.md new file mode 100644 index 00000000..3d3e3f64 --- /dev/null +++ b/docs/tasks-todo/task-x-move-incomplete-tasks-to-today.md @@ -0,0 +1,188 @@ +# Move Incomplete Tasks to Today + +**GitHub Issue:** [#38](https://github.com/dannysmith/taskdn/issues/38) +**Work Directory:** `tdn-desktop/` + +## Requirements + +Add a command that bulk-reschedules overdue incomplete tasks to today. The command sets the `scheduled` date to today for all tasks matching **all three** criteria: + +1. Has a `scheduled` date set +2. Status is NOT `done` or `dropped` +3. The `scheduled` date is strictly before today +4. Not archived — the backend's `listTasks()` only reads direct files in the tasks directory (not subdirectories), so archived tasks in `tasks/archive/` are never returned and require no additional filtering + +Completed and dropped tasks keep their original scheduled date. + +### Surfaces + +The command must be accessible from: + +1. **Command palette** — searchable by name/keywords +2. **Button in the Today column header** — visible in the This Week calendar view only, inside the day header of the column representing today + +## Implementation Plan + +### Step 1: Add `getTasks()` to CommandContext + +The existing `CommandContext` exposes `getAreas()` and `getProjects()` but not tasks. The bulk command needs to read all tasks from the TanStack Query cache. + +**Files to change:** + +- `src/lib/commands/types.ts` — Add `getTasks: () => Task[]` to the `CommandContext` interface, next to `getAreas()` and `getProjects()` in the "Data access" section +- `src/hooks/use-command-context.ts` — Implement `getTasks()` following the exact pattern of `getAreas()`/`getProjects()`: + ```typescript + getTasks: () => { + const tasks = queryClient.getQueryData(vaultQueryKeys.tasks()) + return tasks ?? [] + }, + ``` + +### Step 2: Add the command + +**File:** `src/lib/commands/task-commands.ts` + +Add a new `move-incomplete-to-today` command to the `taskCommands` array. This is a **global command** (not scoped to a selected task), so it does NOT use `isTaskCommandAvailable` or `getTargetTask`. + +```typescript +{ + id: 'move-incomplete-to-today', + labelKey: 'commands.moveIncompleteToToday.label', + descriptionKey: 'commands.moveIncompleteToToday.description', + icon: CalendarArrowUp, // from lucide-react + group: 'tasks', + keywords: ['move', 'incomplete', 'overdue', 'reschedule', 'today', 'catch up', 'past'], + surfaces: { commandPalette: true, appMenu: 'Edit' }, + + execute: async context => { + const today = getTodayISO() + const tasks = context.getTasks() + + const overdueTasks = tasks.filter(task => + task.scheduled && + task.scheduled < today && + task.status !== 'done' && + task.status !== 'dropped' + ) + + if (overdueTasks.length === 0) { + context.showToast(t('commands.moveIncompleteToToday.noTasks'), 'info') + return + } + + markMutationStart() + + const results = await Promise.all( + overdueTasks.map(task => + commands.updateTask({ + id: task.id, + title: null, + status: null, + project: null, + area: null, + scheduled: today, + due: null, + deferUntil: null, + body: null, + }) + ) + ) + + markMutationComplete() + + // Update cache for successful updates, log failures + let successCount = 0 + for (let i = 0; i < results.length; i++) { + const result = results[i] + if (result.status === 'ok') { + context.updateTaskInCache(overdueTasks[i].id, result.data) + successCount++ + } else { + logger.error('Failed to reschedule task', { + taskId: overdueTasks[i].id, + error: result.error, + }) + } + } + + if (successCount === 0) { + context.showToast(t('commands.moveIncompleteToToday.error'), 'error') + } else { + context.showToast( + t('commands.moveIncompleteToToday.success', { count: successCount }), + 'success' + ) + } + }, +} +``` + +**Design notes:** + +- Uses `Promise.all` to update all tasks in parallel rather than sequentially — the Rust backend handles file writes independently and ~50ms per task would add up for many tasks +- String comparison `task.scheduled < today` works correctly because both are ISO date strings (`YYYY-MM-DD`) +- `markMutationStart()`/`markMutationComplete()` wraps the entire batch to prevent file watcher cache invalidation during the operation +- Partial failure is handled gracefully: successful updates are cached, failures logged, toast reflects actual count + +### Step 3: Add i18n strings + +**File:** `locales/en.json` + +Add these keys: + +```json +"commands.moveIncompleteToToday.label": "Move Incomplete Tasks to Today", +"commands.moveIncompleteToToday.description": "Reschedule all overdue incomplete tasks to today", +"commands.moveIncompleteToToday.success": "Moved {{count}} task(s) to today", +"commands.moveIncompleteToToday.noTasks": "No overdue tasks to reschedule", +"commands.moveIncompleteToToday.error": "Failed to reschedule tasks" +``` + +### Step 4: Add button to the Today column in WeekCalendar + +The button should appear in the day header of the column that represents today, only in the This Week calendar view. + +**File: `src/components/calendar/DayColumn.tsx`** + +- Add an optional `onMoveIncompleteToToday?: () => void` prop to `DayColumnProps` +- When `isCurrentDay` is true AND the prop is provided, render a small icon button in the day header between the day name and the date number. Use a subtle style (`ghost` variant, small size) so it doesn't crowd the header. Use the same `CalendarArrowUp` icon as the command for consistency. + +Rough placement in the header: + +```tsx +
+ + {format(date, 'EEE')} + +
+ {isCurrentDay && onMoveIncompleteToToday && ( + + )} + + {format(date, 'd')} + +
+
+``` + +Add a tooltip for discoverability (the existing `Tooltip` component from shadcn/ui). + +**File: `src/components/calendar/WeekCalendar.tsx`** + +- Import `executeCommand` from the command registry and `commandContext` from `use-command-context` +- When rendering DayColumns, pass `onMoveIncompleteToToday` only for the today column: + ```typescript + onMoveIncompleteToToday={isToday(day) + ? () => executeCommand('move-incomplete-to-today', commandContext) + : undefined + } + ``` + +This ensures the button triggers the exact same code path as the command palette and keyboard shortcut. + +### Step 5: Quality checks + +- Run `bun run check:all` to verify TypeScript, ESLint, Prettier, ast-grep, and Rust checks pass +- Manual testing against `dummy-demo-vault/` — the demo vault has tasks with various statuses and scheduled dates that should exercise the filtering logic diff --git a/tdn-desktop/locales/en.json b/tdn-desktop/locales/en.json index 85a378e5..4bd508b7 100644 --- a/tdn-desktop/locales/en.json +++ b/tdn-desktop/locales/en.json @@ -205,6 +205,12 @@ "commands.deleteTask.success": "Task deleted", "commands.deleteTask.error": "Failed to delete task", + "commands.moveIncompleteToToday.label": "Move Incomplete Tasks to Today", + "commands.moveIncompleteToToday.description": "Reschedule all overdue incomplete tasks to today", + "commands.moveIncompleteToToday.success": "Moved {{count}} task(s) to today", + "commands.moveIncompleteToToday.noTasks": "No overdue tasks to reschedule", + "commands.moveIncompleteToToday.error": "Failed to reschedule tasks", + "commands.editScheduledDate.label": "Edit Scheduled Date", "commands.editScheduledDate.description": "Open the scheduled date picker", "commands.editDueDate.label": "Edit Due Date", diff --git a/tdn-desktop/src/components/calendar/DayColumn.tsx b/tdn-desktop/src/components/calendar/DayColumn.tsx index 4f273ae6..b9126410 100644 --- a/tdn-desktop/src/components/calendar/DayColumn.tsx +++ b/tdn-desktop/src/components/calendar/DayColumn.tsx @@ -1,9 +1,15 @@ import { useDroppable } from '@dnd-kit/core' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { format, isToday, isWeekend } from 'date-fns' -import { Flag, Plus } from 'lucide-react' +import { CalendarArrowUp, Flag, Plus } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import type { Task, TaskStatus } from '@/lib/tauri-bindings' import { getCalendarTaskDragId } from '@/types/calendar-order' import { SortableTaskCard } from './DraggableTaskCard' @@ -48,6 +54,8 @@ interface DayColumnProps { onTaskContextMenu?: (task: Task) => void /** Called when + button is clicked to create a task */ onCreateTask?: () => void + /** Called when the "move incomplete to today" button is clicked (only shown for today) */ + onMoveIncompleteToToday?: () => void /** ID of task currently being edited (for auto-focus) */ editingTaskId?: string | null /** Whether this column is being dragged over */ @@ -73,9 +81,11 @@ export function DayColumn({ onNavigateToArea, onTaskContextMenu, onCreateTask, + onMoveIncompleteToToday, editingTaskId, isDropTarget = false, }: DayColumnProps) { + const { t } = useTranslation() const dateString = format(date, 'yyyy-MM-dd') const isCurrentDay = isToday(date) const isWeekendDay = isWeekend(date) @@ -108,16 +118,32 @@ export function DayColumn({ {format(date, 'EEE')} - + {isCurrentDay && onMoveIncompleteToToday && ( + + + + + + {t('commands.moveIncompleteToToday.label')} + + )} - > - {format(date, 'd')} - + + {format(date, 'd')} + + diff --git a/tdn-desktop/src/components/calendar/WeekCalendar.tsx b/tdn-desktop/src/components/calendar/WeekCalendar.tsx index 657c7eaf..31c09fcf 100644 --- a/tdn-desktop/src/components/calendar/WeekCalendar.tsx +++ b/tdn-desktop/src/components/calendar/WeekCalendar.tsx @@ -14,6 +14,8 @@ import { } from 'date-fns' import { ChevronLeft, ChevronRight } from 'lucide-react' +import { executeCommand } from '@/lib/commands/registry' +import { commandContext } from '@/hooks/use-command-context' import { cn } from '@/lib/utils' import type { Task, TaskStatus } from '@/lib/tauri-bindings' import { useCalendarOrder } from '@/hooks/use-calendar-order' @@ -332,6 +334,15 @@ export function WeekCalendar({ onCreateTask={ onCreateTask ? () => handleCreateTask(dateKey) : undefined } + onMoveIncompleteToToday={ + isToday(day) + ? () => + executeCommand( + 'move-incomplete-to-today', + commandContext + ) + : undefined + } editingTaskId={editingTaskId} isDropTarget={isDropTarget} /> diff --git a/tdn-desktop/src/hooks/use-command-context.ts b/tdn-desktop/src/hooks/use-command-context.ts index 3201f4c1..96a20dd9 100644 --- a/tdn-desktop/src/hooks/use-command-context.ts +++ b/tdn-desktop/src/hooks/use-command-context.ts @@ -51,6 +51,10 @@ export const commandContext: CommandContext = { canGoForward: () => useNavigationStore.getState().canGoForward(), // Data access (reads from TanStack Query cache) + getTasks: () => { + const tasks = queryClient.getQueryData(vaultQueryKeys.tasks()) + return tasks ?? [] + }, getAreas: () => { const areas = queryClient.getQueryData(vaultQueryKeys.areas()) return areas ?? [] diff --git a/tdn-desktop/src/hooks/use-global-shortcuts.test.ts b/tdn-desktop/src/hooks/use-global-shortcuts.test.ts index e73fb283..2d0abcc9 100644 --- a/tdn-desktop/src/hooks/use-global-shortcuts.test.ts +++ b/tdn-desktop/src/hooks/use-global-shortcuts.test.ts @@ -39,6 +39,7 @@ describe('useGlobalShortcuts', () => { navigateToArea: vi.fn(), navigateToProject: vi.fn(), navigateToNoArea: vi.fn(), + getTasks: vi.fn(() => []), goBack: vi.fn(), goForward: vi.fn(), canGoBack: vi.fn(() => false), diff --git a/tdn-desktop/src/lib/commands/commands.test.ts b/tdn-desktop/src/lib/commands/commands.test.ts index 4fae0dec..aa52179f 100644 --- a/tdn-desktop/src/lib/commands/commands.test.ts +++ b/tdn-desktop/src/lib/commands/commands.test.ts @@ -131,6 +131,7 @@ const createMockContext = (): CommandContext => ({ goForward: vi.fn(), canGoBack: vi.fn(() => false), canGoForward: vi.fn(() => false), + getTasks: vi.fn(() => []), getAreas: vi.fn(() => []), getProjects: vi.fn(() => []), collapseAllAreas: vi.fn(), @@ -973,6 +974,182 @@ describe('Task Commands', () => { expect(commands.deleteTask).toHaveBeenCalledWith('task-123', false) }) }) + + describe('move-incomplete-to-today', () => { + it('reschedules overdue tasks to today', async () => { + const overdueTask1 = createTestTask({ + id: 'overdue-1', + status: 'ready', + scheduled: '2025-01-10', + }) + const overdueTask2 = createTestTask({ + id: 'overdue-2', + status: 'in-progress', + scheduled: '2025-01-12', + }) + vi.mocked(mockContext.getTasks).mockReturnValue([ + overdueTask1, + overdueTask2, + ]) + + vi.mocked(commands.updateTask) + .mockResolvedValueOnce({ + status: 'ok', + data: { ...overdueTask1, scheduled: '2025-01-15' }, + } as never) + .mockResolvedValueOnce({ + status: 'ok', + data: { ...overdueTask2, scheduled: '2025-01-15' }, + } as never) + + const result = await executeCommand( + 'move-incomplete-to-today', + mockContext + ) + + expect(result.success).toBe(true) + expect(commands.updateTask).toHaveBeenCalledTimes(2) + expect(mockContext.updateTaskInCache).toHaveBeenCalledTimes(2) + expect(mockContext.showToast).toHaveBeenCalledWith( + expect.any(String), + 'success' + ) + }) + + it('shows info toast when no overdue tasks exist', async () => { + vi.mocked(mockContext.getTasks).mockReturnValue([ + createTestTask({ status: 'ready', scheduled: null }), + createTestTask({ status: 'done', scheduled: '2025-01-10' }), + ]) + + const result = await executeCommand( + 'move-incomplete-to-today', + mockContext + ) + + expect(result.success).toBe(true) + expect(commands.updateTask).not.toHaveBeenCalled() + expect(mockContext.showToast).toHaveBeenCalledWith( + expect.any(String), + 'info' + ) + }) + + it('skips done and dropped tasks', async () => { + vi.mocked(mockContext.getTasks).mockReturnValue([ + createTestTask({ + status: 'done', + scheduled: '2025-01-10', + }), + createTestTask({ + status: 'dropped', + scheduled: '2025-01-10', + }), + ]) + + const result = await executeCommand( + 'move-incomplete-to-today', + mockContext + ) + + expect(result.success).toBe(true) + expect(commands.updateTask).not.toHaveBeenCalled() + expect(mockContext.showToast).toHaveBeenCalledWith( + expect.any(String), + 'info' + ) + }) + + it('skips tasks without scheduled dates', async () => { + vi.mocked(mockContext.getTasks).mockReturnValue([ + createTestTask({ status: 'ready', scheduled: null }), + ]) + + const result = await executeCommand( + 'move-incomplete-to-today', + mockContext + ) + + expect(result.success).toBe(true) + expect(commands.updateTask).not.toHaveBeenCalled() + }) + + it('skips tasks scheduled today or in the future', async () => { + vi.mocked(mockContext.getTasks).mockReturnValue([ + createTestTask({ status: 'ready', scheduled: '2099-12-31' }), + ]) + + const result = await executeCommand( + 'move-incomplete-to-today', + mockContext + ) + + expect(result.success).toBe(true) + expect(commands.updateTask).not.toHaveBeenCalled() + }) + + it('handles partial failure gracefully', async () => { + const task1 = createTestTask({ + id: 'task-1', + status: 'ready', + scheduled: '2025-01-10', + }) + const task2 = createTestTask({ + id: 'task-2', + status: 'ready', + scheduled: '2025-01-10', + }) + vi.mocked(mockContext.getTasks).mockReturnValue([task1, task2]) + + vi.mocked(commands.updateTask) + .mockResolvedValueOnce({ + status: 'ok', + data: { ...task1, scheduled: '2025-01-15' }, + } as never) + .mockResolvedValueOnce({ + status: 'error', + error: { type: 'internal', message: 'Write failed' }, + } as never) + + const result = await executeCommand( + 'move-incomplete-to-today', + mockContext + ) + + expect(result.success).toBe(true) + expect(mockContext.updateTaskInCache).toHaveBeenCalledTimes(1) + expect(mockContext.showToast).toHaveBeenCalledWith( + expect.any(String), + 'success' + ) + }) + + it('shows error toast when all updates fail', async () => { + const task = createTestTask({ + id: 'task-1', + status: 'ready', + scheduled: '2025-01-10', + }) + vi.mocked(mockContext.getTasks).mockReturnValue([task]) + + vi.mocked(commands.updateTask).mockResolvedValueOnce({ + status: 'error', + error: { type: 'internal', message: 'Write failed' }, + } as never) + + const result = await executeCommand( + 'move-incomplete-to-today', + mockContext + ) + + expect(result.success).toBe(true) + expect(mockContext.updateTaskInCache).not.toHaveBeenCalled() + expect(mockContext.showToast).toHaveBeenCalledWith( + expect.any(String), + 'error' + ) + }) + }) }) describe('Window Commands', () => { diff --git a/tdn-desktop/src/lib/commands/task-commands.ts b/tdn-desktop/src/lib/commands/task-commands.ts index d2d7d412..36819089 100644 --- a/tdn-desktop/src/lib/commands/task-commands.ts +++ b/tdn-desktop/src/lib/commands/task-commands.ts @@ -7,6 +7,7 @@ */ import { Calendar, + CalendarArrowUp, Copy, CopyPlus, Flag, @@ -266,4 +267,93 @@ export const taskCommands: AppCommand[] = [ context.showToast(t('commands.deleteTask.success'), 'success') }, }, + + // ───────────────────────────────────────────────────────────────────────────── + // Bulk Operations + // ───────────────────────────────────────────────────────────────────────────── + + { + id: 'move-incomplete-to-today', + labelKey: 'commands.moveIncompleteToToday.label', + descriptionKey: 'commands.moveIncompleteToToday.description', + icon: CalendarArrowUp, + group: 'tasks', + keywords: [ + 'move', + 'incomplete', + 'overdue', + 'reschedule', + 'today', + 'catch up', + 'past', + ], + surfaces: { commandPalette: true, appMenu: 'Edit' }, + + execute: async context => { + const today = getTodayISO() + const tasks = context.getTasks() + + const overdueTasks = tasks.filter( + task => + task.scheduled && + task.scheduled < today && + task.status !== 'done' && + task.status !== 'dropped' + ) + + if (overdueTasks.length === 0) { + context.showToast(t('commands.moveIncompleteToToday.noTasks'), 'info') + return + } + + markMutationStart() + + let results: Awaited>[] + try { + results = await Promise.all( + overdueTasks.map(task => + commands.updateTask({ + id: task.id, + title: null, + status: null, + project: null, + area: null, + scheduled: today, + due: null, + deferUntil: null, + body: null, + }) + ) + ) + } finally { + markMutationComplete() + } + + let successCount = 0 + for (const [i, task] of overdueTasks.entries()) { + const result = results[i] + if (!result) continue + if (result.status === 'ok') { + context.updateTaskInCache(task.id, result.data) + successCount++ + } else { + logger.error('Failed to reschedule task', { + taskId: task.id, + error: result.error, + }) + } + } + + if (successCount === 0) { + context.showToast(t('commands.moveIncompleteToToday.error'), 'error') + } else { + context.showToast( + t('commands.moveIncompleteToToday.success', { + count: successCount, + }), + 'success' + ) + } + }, + }, ] diff --git a/tdn-desktop/src/lib/commands/types.ts b/tdn-desktop/src/lib/commands/types.ts index 8f9ddbd3..aa05a203 100644 --- a/tdn-desktop/src/lib/commands/types.ts +++ b/tdn-desktop/src/lib/commands/types.ts @@ -90,6 +90,7 @@ export interface CommandContext { canGoForward: () => boolean // Data access (for dynamic commands) + getTasks: () => Task[] getAreas: () => Area[] getProjects: () => Project[] diff --git a/website/src/content/docs/desktop/views.mdx b/website/src/content/docs/desktop/views.mdx index 93f6302d..c57f263e 100644 --- a/website/src/content/docs/desktop/views.mdx +++ b/website/src/content/docs/desktop/views.mdx @@ -86,6 +86,8 @@ The **This Week** view is intended for short-term planning. It shows the current 2. The only unscheduled tasks shown as cards are those with a **defer until** date and *no scheduled date*, which are shown with a dotted border. Dragging one of these will set its scheduled date and cause it to behave like any other task in this view. 3. The calendar shows **due** tasks at the bottom of each day regardless of if/when they are scheduled. This helps to make hard deadlines visible when planning while ensuring this view only shows your actual plan for the week. +The today column has a small button in its header that moves all incomplete overdue tasks to today. This reschedules any task with a past scheduled date (excluding completed and dropped tasks) to today's date — a quick way to catch up if you've fallen behind. The same action is available via the command palette as **Move Incomplete Tasks to Today**. + Your *This Week* view also includes a Kanban board showing all tasks scheduled for this week by status, which can be helpful when you want a general overview of the week's progress.