From 28d4be718cb6ce5005a9883797bfe49fe6bb7db5 Mon Sep 17 00:00:00 2001 From: Daniel O'Connell Date: Thu, 1 Jan 2026 22:14:20 +0100 Subject: [PATCH] todo items --- frontend/src/App.css | 30 ++ frontend/src/App.jsx | 10 +- frontend/src/components/Dashboard.jsx | 12 +- frontend/src/components/index.js | 1 + frontend/src/components/todo/Tasks.css | 426 ++++++++++++++++++++ frontend/src/components/todo/Tasks.tsx | 332 +++++++++++++++ frontend/src/components/todo/TodoWidget.css | 199 +++++++++ frontend/src/components/todo/TodoWidget.tsx | 196 +++++++++ frontend/src/components/todo/index.ts | 1 + frontend/src/hooks/useSources.ts | 113 +++++- frontend/vite.config.js | 1 + src/memory/api/MCP/servers/organizer.py | 154 +++++++ src/memory/api/app.py | 2 + src/memory/api/tasks.py | 179 ++++++++ src/memory/common/tasks.py | 130 ++++++ tests/memory/common/test_tasks.py | 249 ++++++++++++ 16 files changed, 2023 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/todo/Tasks.css create mode 100644 frontend/src/components/todo/Tasks.tsx create mode 100644 frontend/src/components/todo/TodoWidget.css create mode 100644 frontend/src/components/todo/TodoWidget.tsx create mode 100644 frontend/src/components/todo/index.ts create mode 100644 src/memory/api/tasks.py create mode 100644 src/memory/common/tasks.py create mode 100644 tests/memory/common/test_tasks.py diff --git a/frontend/src/App.css b/frontend/src/App.css index f7ed210..9935454 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2216,4 +2216,34 @@ a.folder-item-name:hover { gap: 0.5rem; min-width: auto; } +} + +/* ============================================ + Dashboard Layout + ============================================ */ + +.dashboard-layout { + display: grid; + grid-template-columns: 320px 1fr; + gap: 2rem; + align-items: start; +} + +.dashboard-sidebar { + position: sticky; + top: 2rem; +} + +.dashboard-content { + min-width: 0; +} + +@media (max-width: 900px) { + .dashboard-layout { + grid-template-columns: 1fr; + } + + .dashboard-sidebar { + position: static; + } } \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ad67054..681db6f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,7 +4,7 @@ import './App.css' import { useAuth } from '@/hooks/useAuth' import { useOAuth } from '@/hooks/useOAuth' -import { Loading, LoginPrompt, AuthError, Dashboard, Search, Sources, Calendar } from '@/components' +import { Loading, LoginPrompt, AuthError, Dashboard, Search, Sources, Calendar, Tasks } from '@/components' // AuthWrapper handles redirects based on auth state const AuthWrapper = () => { @@ -110,6 +110,14 @@ const AuthWrapper = () => { ) } /> + + ) : ( + + ) + } /> + {/* Default redirect */} diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index c59a620..dd8189c 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -17,12 +17,11 @@ const Dashboard = ({ onLogout }) => {

Welcome to your Memory Database!

You are successfully authenticated.

-

Access token is stored in cookies and ready for API calls.

-

🔍 Search

+

Search

Search through your knowledge base

@@ -36,13 +35,18 @@ const Dashboard = ({ onLogout }) => {

View upcoming events from your calendars

+ +

Tasks

+

Manage your todos and tasks

+ +
console.log(await listNotes())}> -

📝 Notes

+

Notes

Create and manage your notes

-

🤖 AI Assistant

+

AI Assistant

Chat with your memory-enhanced AI

diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index a53642f..17f450a 100644 --- a/frontend/src/components/index.js +++ b/frontend/src/components/index.js @@ -3,5 +3,6 @@ export { default as Dashboard } from './Dashboard' export { default as Search } from './search' export { default as Sources } from './sources' export { default as Calendar } from './calendar' +export { default as Tasks } from './todo' export { default as LoginPrompt } from './auth/LoginPrompt' export { default as AuthError } from './auth/AuthError' \ No newline at end of file diff --git a/frontend/src/components/todo/Tasks.css b/frontend/src/components/todo/Tasks.css new file mode 100644 index 0000000..c6e6d96 --- /dev/null +++ b/frontend/src/components/todo/Tasks.css @@ -0,0 +1,426 @@ +.tasks-page { + min-height: 100vh; + background: var(--bg-color, #f3f4f6); + padding: 0; +} + +.tasks-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 2rem; + background: var(--card-bg, #ffffff); + border-bottom: 1px solid var(--border-color, #e5e7eb); + position: sticky; + top: 0; + z-index: 10; +} + +.tasks-header h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary, #1f2937); + flex: 1; +} + +.tasks-header .back-btn { + color: var(--accent-color, #3b82f6); + text-decoration: none; + font-size: 0.875rem; +} + +.tasks-header .back-btn:hover { + text-decoration: underline; +} + +.tasks-stats { + display: flex; + gap: 1rem; +} + +.tasks-stats .stat { + font-size: 0.875rem; + color: var(--text-secondary, #6b7280); +} + +.tasks-content { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +/* Add Task Form */ +.add-task-form { + background: var(--card-bg, #ffffff); + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-bottom: 1.5rem; +} + +.form-row { + display: flex; + gap: 0.75rem; +} + +.form-row.form-options { + margin-top: 0.75rem; +} + +.task-input { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 8px; + font-size: 1rem; + background: var(--input-bg, #f9fafb); +} + +.task-input:focus { + outline: none; + border-color: var(--accent-color, #3b82f6); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.add-btn { + padding: 0.75rem 1.5rem; + background: var(--accent-color, #3b82f6); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease; +} + +.add-btn:hover:not(:disabled) { + background: var(--accent-hover, #2563eb); +} + +.add-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.priority-select, +.date-input { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + font-size: 0.875rem; + background: var(--input-bg, #f9fafb); +} + +.priority-select:focus, +.date-input:focus { + outline: none; + border-color: var(--accent-color, #3b82f6); +} + +/* Filters */ +.tasks-filters { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.filter-btn { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 20px; + background: var(--card-bg, #ffffff); + color: var(--text-secondary, #6b7280); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.filter-btn:hover { + border-color: var(--accent-color, #3b82f6); + color: var(--accent-color, #3b82f6); +} + +.filter-btn.active { + background: var(--accent-color, #3b82f6); + border-color: var(--accent-color, #3b82f6); + color: white; +} + +/* Messages */ +.tasks-error { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 1rem; + color: #dc2626; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.tasks-error button { + padding: 0.5rem 1rem; + background: #dc2626; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.tasks-loading, +.tasks-empty { + text-align: center; + padding: 3rem; + color: var(--text-secondary, #6b7280); + font-size: 1rem; +} + +/* Task List */ +.tasks-list { + list-style: none; + padding: 0; + margin: 0; + background: var(--card-bg, #ffffff); + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.task-item { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-color, #e5e7eb); + transition: background 0.15s ease; +} + +.task-item:last-child { + border-bottom: none; +} + +.task-item:hover { + background: var(--hover-bg, #f9fafb); +} + +.task-item.completed { + opacity: 0.7; + background: var(--completed-bg, #f9fafb); +} + +.task-checkbox { + width: 24px; + height: 24px; + min-width: 24px; + border: 2px solid var(--border-color, #d1d5db); + border-radius: 50%; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + transition: all 0.15s ease; +} + +.task-checkbox:hover:not(:disabled) { + border-color: var(--accent-color, #3b82f6); + background: var(--accent-color, #3b82f6); +} + +.task-checkbox:hover:not(:disabled)::after { + content: '\2713'; + color: white; + font-size: 14px; + font-weight: bold; +} + +.task-checkbox.checked { + border-color: #16a34a; + background: #16a34a; + cursor: default; +} + +.task-checkbox .checkmark { + color: white; + font-size: 14px; + font-weight: bold; +} + +.task-content { + flex: 1; + min-width: 0; +} + +.task-title { + display: block; + font-size: 1rem; + color: var(--text-primary, #1f2937); + line-height: 1.5; + word-break: break-word; +} + +.task-title.done { + text-decoration: line-through; + color: var(--text-secondary, #6b7280); +} + +.task-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.375rem; +} + +.task-priority { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + color: white; + padding: 0.125rem 0.5rem; + border-radius: 4px; +} + +.task-due { + font-size: 0.8125rem; + color: var(--text-secondary, #6b7280); +} + +.task-due.overdue { + color: #dc2626; + font-weight: 600; +} + +.task-due.today { + color: #ea580c; + font-weight: 500; +} + +.task-due.tomorrow { + color: #ca8a04; +} + +.task-status { + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; + padding: 0.125rem 0.5rem; + border-radius: 4px; +} + +.task-status.in-progress { + background: #dbeafe; + color: #1d4ed8; +} + +/* Task Actions */ +.task-actions { + display: flex; + gap: 0.5rem; + opacity: 0; + transition: opacity 0.15s ease; +} + +.task-item:hover .task-actions { + opacity: 1; +} + +.edit-btn, +.delete-btn { + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary, #6b7280); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.edit-btn:hover { + background: #dbeafe; + color: #1d4ed8; +} + +.delete-btn:hover { + background: #fef2f2; + color: #dc2626; +} + +/* Edit Form */ +.task-edit-form { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.task-edit-input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--accent-color, #3b82f6); + border-radius: 6px; + font-size: 1rem; +} + +.task-edit-input:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.save-btn, +.cancel-btn { + padding: 0.375rem 0.75rem; + border: none; + border-radius: 6px; + font-size: 0.875rem; + cursor: pointer; +} + +.save-btn { + background: var(--accent-color, #3b82f6); + color: white; +} + +.save-btn:hover { + background: var(--accent-hover, #2563eb); +} + +.cancel-btn { + background: var(--border-color, #e5e7eb); + color: var(--text-secondary, #6b7280); +} + +.cancel-btn:hover { + background: #d1d5db; +} + +/* Responsive */ +@media (max-width: 640px) { + .tasks-header { + padding: 1rem; + flex-wrap: wrap; + } + + .tasks-content { + padding: 1rem; + } + + .form-row { + flex-direction: column; + } + + .add-btn { + width: 100%; + } + + .task-actions { + opacity: 1; + } +} diff --git a/frontend/src/components/todo/Tasks.tsx b/frontend/src/components/todo/Tasks.tsx new file mode 100644 index 0000000..33829a4 --- /dev/null +++ b/frontend/src/components/todo/Tasks.tsx @@ -0,0 +1,332 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { useSources, TodoTask, TodoTaskFilters } from '@/hooks/useSources' +import './Tasks.css' + +const PRIORITY_ORDER: Record = { urgent: 0, high: 1, medium: 2, low: 3 } +const PRIORITY_COLORS: Record = { + urgent: '#dc2626', + high: '#ea580c', + medium: '#ca8a04', + low: '#16a34a', +} + +type StatusFilter = 'active' | 'completed' | 'all' + +const Tasks = () => { + const { listTasks, completeTask, createTask, updateTask, deleteTask } = useSources() + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [newTaskTitle, setNewTaskTitle] = useState('') + const [newTaskPriority, setNewTaskPriority] = useState('') + const [newTaskDueDate, setNewTaskDueDate] = useState('') + const [isAdding, setIsAdding] = useState(false) + const [statusFilter, setStatusFilter] = useState('active') + const [editingTask, setEditingTask] = useState(null) + const [editTitle, setEditTitle] = useState('') + + const loadTasks = useCallback(async () => { + setLoading(true) + try { + const filters: TodoTaskFilters = { + include_completed: statusFilter === 'completed' || statusFilter === 'all', + limit: 100, + } + if (statusFilter === 'completed') { + filters.status = 'done' + } else if (statusFilter === 'active') { + filters.status = 'pending,in_progress' + } + + const data = await listTasks(filters) + // Sort by priority then due date + const sorted = [...data].sort((a, b) => { + // Completed tasks at the end + if (a.status === 'done' && b.status !== 'done') return 1 + if (a.status !== 'done' && b.status === 'done') return -1 + // Priority first + const aPriority = a.priority ? PRIORITY_ORDER[a.priority] ?? 4 : 4 + const bPriority = b.priority ? PRIORITY_ORDER[b.priority] ?? 4 : 4 + if (aPriority !== bPriority) return aPriority - bPriority + // Then by due date + if (a.due_date && b.due_date) { + return new Date(a.due_date).getTime() - new Date(b.due_date).getTime() + } + if (a.due_date) return -1 + if (b.due_date) return 1 + return 0 + }) + setTasks(sorted) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load tasks') + } finally { + setLoading(false) + } + }, [listTasks, statusFilter]) + + useEffect(() => { + loadTasks() + }, [loadTasks]) + + const handleComplete = async (taskId: number) => { + try { + await completeTask(taskId) + loadTasks() + } catch (e) { + console.error('Failed to complete task:', e) + } + } + + const handleDelete = async (taskId: number) => { + if (!confirm('Are you sure you want to delete this task?')) return + try { + await deleteTask(taskId) + setTasks(prev => prev.filter(t => t.id !== taskId)) + } catch (e) { + console.error('Failed to delete task:', e) + } + } + + const handleAddTask = async (e: React.FormEvent) => { + e.preventDefault() + if (!newTaskTitle.trim()) return + + setIsAdding(true) + try { + await createTask({ + task_title: newTaskTitle.trim(), + priority: newTaskPriority || undefined, + due_date: newTaskDueDate || undefined, + }) + setNewTaskTitle('') + setNewTaskPriority('') + setNewTaskDueDate('') + loadTasks() + } catch (e) { + console.error('Failed to create task:', e) + } finally { + setIsAdding(false) + } + } + + const handleStartEdit = (task: TodoTask) => { + setEditingTask(task.id) + setEditTitle(task.task_title) + } + + const handleSaveEdit = async (taskId: number) => { + if (!editTitle.trim()) return + try { + await updateTask(taskId, { task_title: editTitle.trim() }) + setEditingTask(null) + loadTasks() + } catch (e) { + console.error('Failed to update task:', e) + } + } + + const handleCancelEdit = () => { + setEditingTask(null) + setEditTitle('') + } + + const formatDueDate = (dueDate: string) => { + const date = new Date(dueDate) + const today = new Date() + today.setHours(0, 0, 0, 0) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + const taskDate = new Date(date) + taskDate.setHours(0, 0, 0, 0) + + if (taskDate.getTime() < today.getTime()) { + return { text: 'Overdue', className: 'overdue' } + } + if (taskDate.getTime() === today.getTime()) { + return { text: 'Today', className: 'today' } + } + if (taskDate.getTime() === tomorrow.getTime()) { + return { text: 'Tomorrow', className: 'tomorrow' } + } + return { + text: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + className: '' + } + } + + const activeCount = tasks.filter(t => t.status !== 'done' && t.status !== 'cancelled').length + const completedCount = tasks.filter(t => t.status === 'done').length + + return ( +
+
+ Back +

Tasks

+
+ {activeCount} active + {completedCount} completed +
+
+ +
+ {/* Add Task Form */} +
+
+ setNewTaskTitle(e.target.value)} + disabled={isAdding} + className="task-input" + /> + +
+
+ + setNewTaskDueDate(e.target.value)} + className="date-input" + /> +
+
+ + {/* Filters */} +
+ + + +
+ + {/* Error */} + {error && ( +
+

{error}

+ +
+ )} + + {/* Loading */} + {loading &&
Loading tasks...
} + + {/* Task List */} + {!loading && tasks.length === 0 && ( +
+ {statusFilter === 'completed' + ? 'No completed tasks yet' + : statusFilter === 'active' + ? 'No active tasks. Add one above!' + : 'No tasks yet. Add one above!'} +
+ )} + + {!loading && tasks.length > 0 && ( +
    + {tasks.map(task => ( +
  • + + +
    + {editingTask === task.id ? ( +
    + setEditTitle(e.target.value)} + className="task-edit-input" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveEdit(task.id) + if (e.key === 'Escape') handleCancelEdit() + }} + /> + + +
    + ) : ( + <> + + {task.task_title} + +
    + {task.priority && ( + + {task.priority} + + )} + {task.due_date && ( + + {formatDueDate(task.due_date).text} + + )} + {task.status === 'in_progress' && ( + In Progress + )} +
    + + )} +
    + + {editingTask !== task.id && ( +
    + {task.status !== 'done' && ( + + )} + +
    + )} +
  • + ))} +
+ )} +
+
+ ) +} + +export default Tasks diff --git a/frontend/src/components/todo/TodoWidget.css b/frontend/src/components/todo/TodoWidget.css new file mode 100644 index 0000000..0ec7185 --- /dev/null +++ b/frontend/src/components/todo/TodoWidget.css @@ -0,0 +1,199 @@ +.todo-widget { + background: var(--card-bg, #ffffff); + border-radius: 12px; + padding: 1rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 280px; + max-width: 400px; +} + +.todo-widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color, #e5e7eb); +} + +.todo-widget-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #1f2937); +} + +.todo-count { + background: var(--accent-color, #3b82f6); + color: white; + font-size: 0.75rem; + font-weight: 600; + padding: 0.125rem 0.5rem; + border-radius: 10px; +} + +.todo-add-form { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.todo-add-input { + flex: 1; + padding: 0.5rem; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + font-size: 0.875rem; + background: var(--input-bg, #f9fafb); +} + +.todo-add-input:focus { + outline: none; + border-color: var(--accent-color, #3b82f6); +} + +.todo-add-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: var(--accent-color, #3b82f6); + color: white; + font-size: 1.25rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.todo-add-btn:hover:not(:disabled) { + background: var(--accent-hover, #2563eb); +} + +.todo-add-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.todo-widget-loading, +.todo-widget-error, +.todo-widget-empty { + padding: 1rem; + text-align: center; + color: var(--text-secondary, #6b7280); + font-size: 0.875rem; +} + +.todo-widget-error { + color: #dc2626; +} + +.todo-list { + list-style: none; + padding: 0; + margin: 0; +} + +.todo-item { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-color, #e5e7eb); +} + +.todo-item:last-child { + border-bottom: none; +} + +.todo-checkbox { + width: 20px; + height: 20px; + min-width: 20px; + border: 2px solid var(--border-color, #d1d5db); + border-radius: 50%; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + transition: all 0.15s ease; +} + +.todo-checkbox:hover { + border-color: var(--accent-color, #3b82f6); + background: var(--accent-color, #3b82f6); +} + +.todo-checkbox:hover .checkmark::before { + content: '\2713'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.todo-content { + flex: 1; + min-width: 0; +} + +.todo-title { + display: block; + font-size: 0.875rem; + color: var(--text-primary, #1f2937); + line-height: 1.4; + word-break: break-word; +} + +.todo-meta { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.25rem; +} + +.todo-priority { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + color: white; + padding: 0.125rem 0.375rem; + border-radius: 4px; +} + +.todo-due { + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); +} + +.todo-due.overdue { + color: #dc2626; + font-weight: 600; +} + +.todo-due.today { + color: #ea580c; + font-weight: 500; +} + +.todo-due.tomorrow { + color: #ca8a04; +} + +.todo-widget-footer { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border-color, #e5e7eb); + text-align: center; +} + +.view-all-link { + font-size: 0.875rem; + color: var(--accent-color, #3b82f6); + text-decoration: none; +} + +.view-all-link:hover { + text-decoration: underline; +} diff --git a/frontend/src/components/todo/TodoWidget.tsx b/frontend/src/components/todo/TodoWidget.tsx new file mode 100644 index 0000000..41e3450 --- /dev/null +++ b/frontend/src/components/todo/TodoWidget.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { useSources, TodoTask } from '@/hooks/useSources' +import './TodoWidget.css' + +const PRIORITY_ORDER = { urgent: 0, high: 1, medium: 2, low: 3 } +const PRIORITY_COLORS = { + urgent: '#dc2626', + high: '#ea580c', + medium: '#ca8a04', + low: '#16a34a', +} + +interface TodoWidgetProps { + maxItems?: number +} + +const TodoWidget = ({ maxItems = 5 }: TodoWidgetProps) => { + const { listTasks, completeTask, createTask } = useSources() + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [newTaskTitle, setNewTaskTitle] = useState('') + const [isAdding, setIsAdding] = useState(false) + + const loadTasks = useCallback(async () => { + try { + const data = await listTasks({ limit: maxItems + 5 }) + // Sort by priority then due date + const sorted = [...data].sort((a, b) => { + // Priority first + const aPriority = a.priority ? PRIORITY_ORDER[a.priority] : 4 + const bPriority = b.priority ? PRIORITY_ORDER[b.priority] : 4 + if (aPriority !== bPriority) return aPriority - bPriority + // Then by due date + if (a.due_date && b.due_date) { + return new Date(a.due_date).getTime() - new Date(b.due_date).getTime() + } + if (a.due_date) return -1 + if (b.due_date) return 1 + return 0 + }) + setTasks(sorted.slice(0, maxItems)) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load tasks') + } finally { + setLoading(false) + } + }, [listTasks, maxItems]) + + useEffect(() => { + loadTasks() + }, [loadTasks]) + + const handleComplete = async (taskId: number) => { + try { + await completeTask(taskId) + // Remove from list + setTasks(prev => prev.filter(t => t.id !== taskId)) + // Reload to get next task if available + loadTasks() + } catch (e) { + console.error('Failed to complete task:', e) + } + } + + const handleAddTask = async (e: React.FormEvent) => { + e.preventDefault() + if (!newTaskTitle.trim()) return + + setIsAdding(true) + try { + await createTask({ task_title: newTaskTitle.trim() }) + setNewTaskTitle('') + loadTasks() + } catch (e) { + console.error('Failed to create task:', e) + } finally { + setIsAdding(false) + } + } + + const formatDueDate = (dueDate: string) => { + const date = new Date(dueDate) + const today = new Date() + today.setHours(0, 0, 0, 0) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + const taskDate = new Date(date) + taskDate.setHours(0, 0, 0, 0) + + if (taskDate.getTime() < today.getTime()) { + return { text: 'Overdue', className: 'overdue' } + } + if (taskDate.getTime() === today.getTime()) { + return { text: 'Today', className: 'today' } + } + if (taskDate.getTime() === tomorrow.getTime()) { + return { text: 'Tomorrow', className: 'tomorrow' } + } + return { + text: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + className: '' + } + } + + if (loading) { + return ( +
+
+

Tasks

+
+
Loading...
+
+ ) + } + + if (error) { + return ( +
+
+

Tasks

+
+
{error}
+
+ ) + } + + return ( +
+
+

Tasks

+ {tasks.length} +
+ +
+ setNewTaskTitle(e.target.value)} + disabled={isAdding} + className="todo-add-input" + /> + +
+ + {tasks.length === 0 ? ( +
+ No pending tasks +
+ ) : ( +
    + {tasks.map(task => ( +
  • + +
    + {task.task_title} +
    + {task.priority && ( + + {task.priority} + + )} + {task.due_date && ( + + {formatDueDate(task.due_date).text} + + )} +
    +
    +
  • + ))} +
+ )} + +
+ View all tasks +
+
+ ) +} + +export default TodoWidget diff --git a/frontend/src/components/todo/index.ts b/frontend/src/components/todo/index.ts new file mode 100644 index 0000000..19d1d97 --- /dev/null +++ b/frontend/src/components/todo/index.ts @@ -0,0 +1 @@ +export { default } from './Tasks' diff --git a/frontend/src/hooks/useSources.ts b/frontend/src/hooks/useSources.ts index 1c54aa1..e24cd12 100644 --- a/frontend/src/hooks/useSources.ts +++ b/frontend/src/hooks/useSources.ts @@ -282,12 +282,52 @@ export interface CalendarAccountUpdate { active?: boolean } -// Task response -export interface TaskResponse { +// Celery task response (for async jobs) +export interface CeleryTaskResponse { task_id: string status: string } +// Todo/Task types +export interface TodoTask { + id: number + task_title: string + due_date: string | null + priority: 'low' | 'medium' | 'high' | 'urgent' | null + status: 'pending' | 'in_progress' | 'done' | 'cancelled' + recurrence: string | null + completed_at: string | null + source_item_id: number | null + tags: string[] + inserted_at: string | null +} + +export interface TodoTaskCreate { + task_title: string + due_date?: string + priority?: 'low' | 'medium' | 'high' | 'urgent' + recurrence?: string + tags?: string[] +} + +export interface TodoTaskUpdate { + task_title?: string + due_date?: string + priority?: 'low' | 'medium' | 'high' | 'urgent' + status?: 'pending' | 'in_progress' | 'done' | 'cancelled' + recurrence?: string + tags?: string[] +} + +export interface TodoTaskFilters { + status?: string + priority?: string + include_completed?: boolean + due_before?: string + due_after?: string + limit?: number +} + // Calendar Event export interface CalendarEvent { id: number @@ -340,7 +380,7 @@ export const useSources = () => { if (!response.ok) throw new Error('Failed to delete email account') }, [apiCall]) - const syncEmailAccount = useCallback(async (id: number): Promise => { + const syncEmailAccount = useCallback(async (id: number): Promise => { const response = await apiCall(`/email-accounts/${id}/sync`, { method: 'POST' }) if (!response.ok) throw new Error('Failed to sync email account') return response.json() @@ -389,7 +429,7 @@ export const useSources = () => { if (!response.ok) throw new Error('Failed to delete article feed') }, [apiCall]) - const syncArticleFeed = useCallback(async (id: number): Promise => { + const syncArticleFeed = useCallback(async (id: number): Promise => { const response = await apiCall(`/article-feeds/${id}/sync`, { method: 'POST' }) if (!response.ok) throw new Error('Failed to sync article feed') return response.json() @@ -477,7 +517,7 @@ export const useSources = () => { if (!response.ok) throw new Error('Failed to delete GitHub repo') }, [apiCall]) - const syncGithubRepo = useCallback(async (accountId: number, repoId: number, forceFull = false): Promise => { + const syncGithubRepo = useCallback(async (accountId: number, repoId: number, forceFull = false): Promise => { const response = await apiCall(`/github/accounts/${accountId}/repos/${repoId}/sync?force_full=${forceFull}`, { method: 'POST' }) if (!response.ok) throw new Error('Failed to sync GitHub repo') return response.json() @@ -546,7 +586,7 @@ export const useSources = () => { if (!response.ok) throw new Error('Failed to delete Google folder') }, [apiCall]) - const syncGoogleFolder = useCallback(async (accountId: number, folderId: number, forceFull = false): Promise => { + const syncGoogleFolder = useCallback(async (accountId: number, folderId: number, forceFull = false): Promise => { const response = await apiCall(`/google-drive/accounts/${accountId}/folders/${folderId}/sync?force_full=${forceFull}`, { method: 'POST' }) if (!response.ok) throw new Error('Failed to sync Google folder') return response.json() @@ -629,7 +669,7 @@ export const useSources = () => { if (!response.ok) throw new Error('Failed to delete calendar account') }, [apiCall]) - const syncCalendarAccount = useCallback(async (id: number, forceFull = false): Promise => { + const syncCalendarAccount = useCallback(async (id: number, forceFull = false): Promise => { const response = await apiCall(`/calendar-accounts/${id}/sync?force_full=${forceFull}`, { method: 'POST' }) if (!response.ok) throw new Error('Failed to sync calendar account') return response.json() @@ -650,6 +690,59 @@ export const useSources = () => { return response.json() }, [apiCall]) + // === Tasks/Todos === + + const listTasks = useCallback(async (filters: TodoTaskFilters = {}): Promise => { + const params = new URLSearchParams() + if (filters.status) params.append('status', filters.status) + if (filters.priority) params.append('priority', filters.priority) + if (filters.include_completed) params.append('include_completed', 'true') + if (filters.due_before) params.append('due_before', filters.due_before) + if (filters.due_after) params.append('due_after', filters.due_after) + if (filters.limit) params.append('limit', filters.limit.toString()) + + const queryString = params.toString() + const url = queryString ? `/tasks?${queryString}` : '/tasks' + const response = await apiCall(url) + if (!response.ok) throw new Error('Failed to fetch tasks') + return response.json() + }, [apiCall]) + + const createTask = useCallback(async (data: TodoTaskCreate): Promise => { + const response = await apiCall('/tasks', { + method: 'POST', + body: JSON.stringify(data), + }) + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || 'Failed to create task') + } + return response.json() + }, [apiCall]) + + const updateTask = useCallback(async (id: number, data: TodoTaskUpdate): Promise => { + const response = await apiCall(`/tasks/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || 'Failed to update task') + } + return response.json() + }, [apiCall]) + + const deleteTask = useCallback(async (id: number): Promise => { + const response = await apiCall(`/tasks/${id}`, { method: 'DELETE' }) + if (!response.ok) throw new Error('Failed to delete task') + }, [apiCall]) + + const completeTask = useCallback(async (id: number): Promise => { + const response = await apiCall(`/tasks/${id}/complete`, { method: 'POST' }) + if (!response.ok) throw new Error('Failed to complete task') + return response.json() + }, [apiCall]) + return { // Email listEmailAccounts, @@ -697,5 +790,11 @@ export const useSources = () => { syncCalendarAccount, // Calendar Events getUpcomingEvents, + // Tasks/Todos + listTasks, + createTask, + updateTask, + deleteTask, + completeTask, } } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 9efeece..e8a844c 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -24,6 +24,7 @@ export default defineConfig({ '/article-feeds': 'http://localhost:8000', '/github': 'http://localhost:8000', '/google-drive': 'http://localhost:8000', + '/tasks': 'http://localhost:8000', }, }, }) diff --git a/src/memory/api/MCP/servers/organizer.py b/src/memory/api/MCP/servers/organizer.py index 25a9234..d470b07 100644 --- a/src/memory/api/MCP/servers/organizer.py +++ b/src/memory/api/MCP/servers/organizer.py @@ -3,11 +3,15 @@ MCP subserver for organizational tools: calendar, todos, reminders. """ import logging +from datetime import datetime, timezone +from typing import Literal from fastmcp import FastMCP from memory.common.calendar import get_events_in_range, parse_date_range from memory.common.db.connection import make_session +from memory.common.db.models import Task +from memory.common.tasks import get_tasks, complete_task, task_to_dict logger = logging.getLogger(__name__) @@ -43,3 +47,153 @@ async def get_upcoming_events( with make_session() as session: return get_events_in_range(session, range_start, range_end, limit) + + +# ============================================================================= +# Task/Todo Tools +# ============================================================================= + + +@organizer_mcp.tool() +async def list_tasks( + status: Literal["pending", "in_progress", "done", "cancelled"] | None = None, + priority: Literal["low", "medium", "high", "urgent"] | None = None, + include_completed: bool = False, + limit: int = 50, +) -> list[dict]: + """ + List the user's tasks/todos with optional filtering. + Use to check what tasks are pending, find high-priority items, or review completed work. + + Args: + status: Filter by status (pending, in_progress, done, cancelled) + priority: Filter by priority (low, medium, high, urgent) + include_completed: Include done/cancelled tasks (default False) + limit: Maximum tasks to return (default 50, max 200) + + Returns: List of tasks with id, task_title, due_date, priority, status, + recurrence, completed_at, tags. Sorted by due_date then priority. + """ + limit = min(max(limit, 1), 200) + + with make_session() as session: + return get_tasks( + session, + status=status, + priority=priority, + include_completed=include_completed, + limit=limit, + ) + + +@organizer_mcp.tool() +async def create_task( + title: str, + due_date: str | None = None, + priority: Literal["low", "medium", "high", "urgent"] | None = None, + recurrence: str | None = None, + tags: list[str] | None = None, +) -> dict: + """ + Create a new task/todo for the user. + Use when the user asks you to remember something, add a task, or create a reminder. + + Args: + title: The task title/description (required) + due_date: ISO format due date (e.g., "2024-01-15" or "2024-01-15T09:00:00Z") + priority: Priority level (low, medium, high, urgent) + recurrence: RRULE format for recurring tasks (e.g., "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR") + tags: List of tags for categorization + + Returns: The created task with id, task_title, due_date, priority, status, etc. + """ + parsed_due_date = None + if due_date: + try: + parsed_due_date = datetime.fromisoformat(due_date.replace("Z", "+00:00")) + except ValueError: + raise ValueError(f"Invalid due_date format: {due_date}") + + with make_session() as session: + task = Task( + task_title=title, + due_date=parsed_due_date, + priority=priority, + status="pending", + recurrence=recurrence, + tags=tags or [], + sha256=b"task:" + title.encode()[:24], + ) + session.add(task) + session.commit() + session.refresh(task) + return task_to_dict(task) + + +@organizer_mcp.tool() +async def update_task( + task_id: int, + title: str | None = None, + due_date: str | None = None, + priority: Literal["low", "medium", "high", "urgent"] | None = None, + status: Literal["pending", "in_progress", "done", "cancelled"] | None = None, + tags: list[str] | None = None, +) -> dict: + """ + Update an existing task. + Use to modify task details, change priority, or update status. + + Args: + task_id: ID of the task to update (required) + title: New task title + due_date: New due date in ISO format + priority: New priority (low, medium, high, urgent) + status: New status (pending, in_progress, done, cancelled) + tags: New tags list + + Returns: The updated task, or error if not found. + """ + with make_session() as session: + task = session.get(Task, task_id) + if not task: + raise ValueError(f"Task {task_id} not found") + + if title is not None: + task.task_title = title + if due_date is not None: + try: + task.due_date = datetime.fromisoformat(due_date.replace("Z", "+00:00")) + except ValueError: + raise ValueError(f"Invalid due_date format: {due_date}") + if priority is not None: + task.priority = priority + if status is not None: + task.status = status + if status == "done" and not task.completed_at: + task.completed_at = datetime.now(timezone.utc) + elif status in ("pending", "in_progress"): + task.completed_at = None + if tags is not None: + task.tags = tags + + session.commit() + session.refresh(task) + return task_to_dict(task) + + +@organizer_mcp.tool() +async def complete_task_by_id(task_id: int) -> dict: + """ + Mark a task as complete. + Use when the user says they finished a task or want to check it off. + + Args: + task_id: ID of the task to complete + + Returns: The completed task with updated status and completed_at timestamp. + """ + with make_session() as session: + task = complete_task(session, task_id) + if not task: + raise ValueError(f"Task {task_id} not found") + return task_to_dict(task) diff --git a/src/memory/api/app.py b/src/memory/api/app.py index c3bd5bf..8b45c43 100644 --- a/src/memory/api/app.py +++ b/src/memory/api/app.py @@ -28,6 +28,7 @@ from memory.api.email_accounts import router as email_accounts_router from memory.api.article_feeds import router as article_feeds_router from memory.api.github_sources import router as github_sources_router from memory.api.calendar_accounts import router as calendar_accounts_router +from memory.api.tasks import router as tasks_router from memory.api.MCP.base import mcp logger = logging.getLogger(__name__) @@ -159,6 +160,7 @@ app.include_router(email_accounts_router) app.include_router(article_feeds_router) app.include_router(github_sources_router) app.include_router(calendar_accounts_router) +app.include_router(tasks_router) # Add health check to MCP server instead of main app diff --git a/src/memory/api/tasks.py b/src/memory/api/tasks.py new file mode 100644 index 0000000..6cfeae8 --- /dev/null +++ b/src/memory/api/tasks.py @@ -0,0 +1,179 @@ +"""API endpoints for Task management.""" + +from datetime import datetime, timezone +from typing import Literal, cast + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from memory.api.auth import get_current_user +from memory.common.db.connection import get_session +from memory.common.db.models import User, Task +from memory.common.tasks import get_tasks, complete_task, task_to_dict + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +class TaskCreate(BaseModel): + task_title: str + due_date: datetime | None = None + priority: Literal["low", "medium", "high", "urgent"] | None = None + recurrence: str | None = None + tags: list[str] = [] + + +class TaskUpdate(BaseModel): + task_title: str | None = None + due_date: datetime | None = None + priority: Literal["low", "medium", "high", "urgent"] | None = None + status: Literal["pending", "in_progress", "done", "cancelled"] | None = None + recurrence: str | None = None + tags: list[str] | None = None + + +class TaskResponse(BaseModel): + id: int + task_title: str + due_date: str | None + priority: str | None + status: str + recurrence: str | None + completed_at: str | None + source_item_id: int | None + tags: list[str] + inserted_at: str | None + + +@router.get("") +def list_tasks( + status: str | None = Query(default=None, description="Filter by status"), + priority: str | None = Query(default=None, description="Filter by priority"), + include_completed: bool = Query(default=False, description="Include done/cancelled tasks"), + due_before: datetime | None = Query(default=None, description="Tasks due before this date"), + due_after: datetime | None = Query(default=None, description="Tasks due after this date"), + limit: int = Query(default=100, ge=1, le=500), + user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> list[TaskResponse]: + """List tasks with optional filters.""" + # Parse comma-separated status/priority if provided + status_list = status.split(",") if status else None + priority_list = priority.split(",") if priority else None + + tasks = get_tasks( + db, + status=status_list, + priority=priority_list, + include_completed=include_completed, + due_before=due_before, + due_after=due_after, + limit=limit, + ) + + return [TaskResponse(**t) for t in tasks] + + +@router.post("") +def create_task( + data: TaskCreate, + user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> TaskResponse: + """Create a new task.""" + task = Task( + task_title=data.task_title, + due_date=data.due_date, + priority=data.priority, + status="pending", + recurrence=data.recurrence, + tags=data.tags, + sha256=b"task:" + data.task_title.encode()[:24], # Simple hash for tasks + ) + db.add(task) + db.commit() + db.refresh(task) + + return TaskResponse(**task_to_dict(task)) + + +@router.get("/{task_id}") +def get_task( + task_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> TaskResponse: + """Get a single task by ID.""" + task = db.get(Task, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + return TaskResponse(**task_to_dict(task)) + + +@router.patch("/{task_id}") +def update_task( + task_id: int, + updates: TaskUpdate, + user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> TaskResponse: + """Update a task.""" + task = db.get(Task, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + if updates.task_title is not None: + task.task_title = updates.task_title + if updates.due_date is not None: + task.due_date = updates.due_date + if updates.priority is not None: + task.priority = updates.priority + if updates.status is not None: + task.status = updates.status + # Set completed_at when marking as done + if updates.status == "done" and not task.completed_at: + task.completed_at = datetime.now(timezone.utc) + # Clear completed_at if reopening + elif updates.status in ("pending", "in_progress"): + task.completed_at = None + if updates.recurrence is not None: + task.recurrence = updates.recurrence + if updates.tags is not None: + task.tags = updates.tags + + db.commit() + db.refresh(task) + + return TaskResponse(**task_to_dict(task)) + + +@router.delete("/{task_id}") +def delete_task( + task_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_session), +): + """Delete a task.""" + task = db.get(Task, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + db.delete(task) + db.commit() + + return {"status": "deleted"} + + +@router.post("/{task_id}/complete") +def mark_task_complete( + task_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> TaskResponse: + """Mark a task as complete.""" + task = complete_task(db, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + return TaskResponse(**task_to_dict(task)) diff --git a/src/memory/common/tasks.py b/src/memory/common/tasks.py new file mode 100644 index 0000000..e6c2c01 --- /dev/null +++ b/src/memory/common/tasks.py @@ -0,0 +1,130 @@ +""" +Common task utilities for task querying and management. +""" + +from datetime import datetime, timezone +from typing import TypedDict + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from memory.common.db.models import Task + + +class TaskDict(TypedDict): + id: int + task_title: str + due_date: str | None + priority: str | None + status: str + recurrence: str | None + completed_at: str | None + source_item_id: int | None + tags: list[str] + inserted_at: str | None + + +def task_to_dict(task: Task) -> TaskDict: + """Convert a Task model to a dictionary.""" + return TaskDict( + id=task.id, # type: ignore + task_title=task.task_title or "", # type: ignore + due_date=task.due_date.isoformat() if task.due_date else None, + priority=task.priority, # type: ignore + status=task.status or "pending", # type: ignore + recurrence=task.recurrence, # type: ignore + completed_at=task.completed_at.isoformat() if task.completed_at else None, + source_item_id=task.source_item_id, # type: ignore + tags=list(task.tags or []), # type: ignore + inserted_at=task.inserted_at.isoformat() if task.inserted_at else None, + ) + + +def get_tasks( + session: Session, + status: str | list[str] | None = None, + priority: str | list[str] | None = None, + include_completed: bool = False, + due_before: datetime | None = None, + due_after: datetime | None = None, + limit: int = 100, +) -> list[TaskDict]: + """Get tasks with optional filters. + + Args: + session: Database session + status: Filter by status(es) - single value or list + priority: Filter by priority(ies) - single value or list + include_completed: Whether to include done/cancelled tasks + due_before: Only tasks due before this date + due_after: Only tasks due after this date + limit: Maximum number of tasks to return + + Returns: + List of task dictionaries, sorted by due_date (nulls last), then priority + """ + query = session.query(Task) + + # Status filter + if status: + if isinstance(status, str): + query = query.filter(Task.status == status) + else: + query = query.filter(Task.status.in_(status)) + elif not include_completed: + # By default, exclude done/cancelled + query = query.filter(Task.status.in_(["pending", "in_progress"])) + + # Priority filter + if priority: + if isinstance(priority, str): + query = query.filter(Task.priority == priority) + else: + query = query.filter(Task.priority.in_(priority)) + + # Due date filters + if due_before: + query = query.filter( + or_(Task.due_date.is_(None), Task.due_date <= due_before) + ) + if due_after: + query = query.filter(Task.due_date >= due_after) + + # Order by due_date (nulls last), then by priority + # Priority order: urgent > high > medium > low > null + priority_order = """ + CASE priority + WHEN 'urgent' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + ELSE 5 + END + """ + query = query.order_by( + Task.due_date.asc().nullslast(), + Task.priority.desc().nullslast(), + Task.inserted_at.desc(), + ) + + tasks = query.limit(limit).all() + return [task_to_dict(t) for t in tasks] + + +def complete_task(session: Session, task_id: int) -> Task | None: + """Mark a task as complete. + + Args: + session: Database session + task_id: ID of the task to complete + + Returns: + The updated task, or None if not found + """ + task = session.get(Task, task_id) + if task: + task.status = "done" + task.completed_at = datetime.now(timezone.utc) + session.commit() + session.refresh(task) + return task diff --git a/tests/memory/common/test_tasks.py b/tests/memory/common/test_tasks.py new file mode 100644 index 0000000..1c6c5c8 --- /dev/null +++ b/tests/memory/common/test_tasks.py @@ -0,0 +1,249 @@ +"""Tests for common task utilities.""" + +import pytest +from datetime import datetime, timedelta, timezone + +from memory.common.tasks import ( + task_to_dict, + get_tasks, + complete_task, + TaskDict, +) +from memory.common.db.models import Task + + +# ============================================================================= +# Tests for task_to_dict +# ============================================================================= + + +def test_task_to_dict_basic(): + """Test converting a task to dict with all fields.""" + task = Task( + task_title="Test Task", + due_date=datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc), + priority="high", + status="pending", + recurrence="FREQ=DAILY", + tags=["work", "important"], + sha256=b"test" + b"0" * 28, + ) + task.id = 123 + + result = task_to_dict(task) + + assert result["id"] == 123 + assert result["task_title"] == "Test Task" + assert result["priority"] == "high" + assert result["status"] == "pending" + assert result["recurrence"] == "FREQ=DAILY" + assert result["tags"] == ["work", "important"] + assert "2024-01-15" in result["due_date"] + assert result["inserted_at"] is None # Not set for non-persisted task + + +def test_task_to_dict_minimal(): + """Test converting a task with minimal fields.""" + task = Task( + task_title="Simple Task", + status="pending", + sha256=b"test" + b"0" * 28, + ) + task.id = 1 + + result = task_to_dict(task) + + assert result["id"] == 1 + assert result["task_title"] == "Simple Task" + assert result["due_date"] is None + assert result["priority"] is None + assert result["status"] == "pending" + assert result["completed_at"] is None + + +def test_task_to_dict_completed(): + """Test converting a completed task.""" + completed_at = datetime(2024, 1, 20, 15, 30, 0, tzinfo=timezone.utc) + task = Task( + task_title="Done Task", + status="done", + completed_at=completed_at, + sha256=b"test" + b"0" * 28, + ) + task.id = 2 + + result = task_to_dict(task) + + assert result["status"] == "done" + assert "2024-01-20" in result["completed_at"] + + +# ============================================================================= +# Tests for get_tasks (require database) +# ============================================================================= + + +@pytest.fixture +def sample_tasks(db_session): + """Create sample tasks for testing.""" + tasks = [ + Task( + task_title="Urgent Task", + priority="urgent", + status="pending", + due_date=datetime(2024, 1, 10, tzinfo=timezone.utc), + sha256=b"u" * 32, + ), + Task( + task_title="High Priority", + priority="high", + status="in_progress", + due_date=datetime(2024, 1, 15, tzinfo=timezone.utc), + sha256=b"h" * 32, + ), + Task( + task_title="Low Priority", + priority="low", + status="pending", + due_date=datetime(2024, 1, 20, tzinfo=timezone.utc), + sha256=b"l" * 32, + ), + Task( + task_title="No Due Date", + priority="medium", + status="pending", + sha256=b"n" * 32, + ), + Task( + task_title="Completed Task", + priority="high", + status="done", + completed_at=datetime(2024, 1, 12, tzinfo=timezone.utc), + sha256=b"d" * 32, + ), + ] + for t in tasks: + db_session.add(t) + db_session.commit() + return tasks + + +def test_get_tasks_default_excludes_completed(db_session, sample_tasks): + """Test that completed tasks are excluded by default.""" + tasks = get_tasks(db_session) + + titles = [t["task_title"] for t in tasks] + assert "Completed Task" not in titles + assert "Urgent Task" in titles + + +def test_get_tasks_include_completed(db_session, sample_tasks): + """Test including completed tasks.""" + tasks = get_tasks(db_session, include_completed=True) + + titles = [t["task_title"] for t in tasks] + assert "Completed Task" in titles + + +def test_get_tasks_filter_by_status(db_session, sample_tasks): + """Test filtering by status.""" + tasks = get_tasks(db_session, status="in_progress") + + assert len(tasks) == 1 + assert tasks[0]["task_title"] == "High Priority" + + +def test_get_tasks_filter_by_priority(db_session, sample_tasks): + """Test filtering by priority.""" + tasks = get_tasks(db_session, priority="urgent") + + assert len(tasks) == 1 + assert tasks[0]["task_title"] == "Urgent Task" + + +def test_get_tasks_filter_by_multiple_statuses(db_session, sample_tasks): + """Test filtering by multiple statuses.""" + tasks = get_tasks(db_session, status=["pending", "in_progress"]) + + assert len(tasks) == 4 + + +def test_get_tasks_filter_by_due_before(db_session, sample_tasks): + """Test filtering by due_before.""" + cutoff = datetime(2024, 1, 16, tzinfo=timezone.utc) + tasks = get_tasks(db_session, due_before=cutoff) + + titles = [t["task_title"] for t in tasks] + assert "Urgent Task" in titles + assert "High Priority" in titles + # "No Due Date" is included because due_before allows null dates + assert "No Due Date" in titles + + +def test_get_tasks_respects_limit(db_session, sample_tasks): + """Test that limit is respected.""" + tasks = get_tasks(db_session, limit=2) + + assert len(tasks) == 2 + + +def test_get_tasks_sorted_by_due_date_and_priority(db_session, sample_tasks): + """Test that tasks are sorted by due date then priority.""" + tasks = get_tasks(db_session) + + # First should be urgent (earliest due + highest priority) + assert tasks[0]["task_title"] == "Urgent Task" + + +def test_get_tasks_empty_database(db_session): + """Test with no tasks in database.""" + tasks = get_tasks(db_session) + + assert tasks == [] + + +# ============================================================================= +# Tests for complete_task +# ============================================================================= + + +def test_complete_task_marks_done(db_session): + """Test completing a task sets status and completed_at.""" + task = Task( + task_title="To Complete", + status="pending", + sha256=b"c" * 32, + ) + db_session.add(task) + db_session.commit() + + result = complete_task(db_session, task.id) + + assert result.status == "done" + assert result.completed_at is not None + + +def test_complete_task_not_found(db_session): + """Test completing a non-existent task returns None.""" + result = complete_task(db_session, 99999) + + assert result is None + + +def test_complete_task_already_done(db_session): + """Test completing an already-done task is idempotent.""" + original_time = datetime(2024, 1, 10, tzinfo=timezone.utc) + task = Task( + task_title="Already Done", + status="done", + completed_at=original_time, + sha256=b"a" * 32, + ) + db_session.add(task) + db_session.commit() + + result = complete_task(db_session, task.id) + + # Should update to new completion time + assert result.status == "done" + assert result.completed_at != original_time