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 (
+
+
+
+
+ {/* Add Task Form */}
+
+
+ {/* Filters */}
+
+
+
+
+
+
+ {/* 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 && (
+
+ )}
+
+
+ )
+}
+
+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 (
+
+ )
+ }
+
+ return (
+
+
+
Tasks
+ {tasks.length}
+
+
+
+
+ {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