todo items

This commit is contained in:
Daniel O'Connell 2026-01-01 22:14:20 +01:00
parent 53f97485c2
commit 28d4be718c
16 changed files with 2023 additions and 12 deletions

View File

@ -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;
}
}

View File

@ -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 = () => {
)
} />
<Route path="/ui/tasks" element={
isAuthenticated ? (
<Tasks />
) : (
<Navigate to="/ui/login" replace />
)
} />
{/* Default redirect */}
<Route path="/" element={
<Navigate to={isAuthenticated ? "/ui/dashboard" : "/ui/login"} replace />

View File

@ -17,12 +17,11 @@ const Dashboard = ({ onLogout }) => {
<div className="welcome">
<h2>Welcome to your Memory Database!</h2>
<p>You are successfully authenticated.</p>
<p>Access token is stored in cookies and ready for API calls.</p>
</div>
<div className="features">
<Link to="/ui/search" className="feature-card">
<h3>🔍 Search</h3>
<h3>Search</h3>
<p>Search through your knowledge base</p>
</Link>
@ -36,13 +35,18 @@ const Dashboard = ({ onLogout }) => {
<p>View upcoming events from your calendars</p>
</Link>
<Link to="/ui/tasks" className="feature-card">
<h3>Tasks</h3>
<p>Manage your todos and tasks</p>
</Link>
<div className="feature-card" onClick={async () => console.log(await listNotes())}>
<h3>📝 Notes</h3>
<h3>Notes</h3>
<p>Create and manage your notes</p>
</div>
<div className="feature-card">
<h3>🤖 AI Assistant</h3>
<h3>AI Assistant</h3>
<p>Chat with your memory-enhanced AI</p>
</div>
</div>

View File

@ -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'

View File

@ -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;
}
}

View File

@ -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<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
const PRIORITY_COLORS: Record<string, string> = {
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<TodoTask[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newTaskTitle, setNewTaskTitle] = useState('')
const [newTaskPriority, setNewTaskPriority] = useState<string>('')
const [newTaskDueDate, setNewTaskDueDate] = useState('')
const [isAdding, setIsAdding] = useState(false)
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active')
const [editingTask, setEditingTask] = useState<number | null>(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 (
<div className="tasks-page">
<header className="tasks-header">
<Link to="/ui/dashboard" className="back-btn">Back</Link>
<h1>Tasks</h1>
<div className="tasks-stats">
<span className="stat">{activeCount} active</span>
<span className="stat">{completedCount} completed</span>
</div>
</header>
<div className="tasks-content">
{/* Add Task Form */}
<form onSubmit={handleAddTask} className="add-task-form">
<div className="form-row">
<input
type="text"
placeholder="What needs to be done?"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
disabled={isAdding}
className="task-input"
/>
<button type="submit" disabled={isAdding || !newTaskTitle.trim()} className="add-btn">
Add Task
</button>
</div>
<div className="form-row form-options">
<select
value={newTaskPriority}
onChange={(e) => setNewTaskPriority(e.target.value)}
className="priority-select"
>
<option value="">No priority</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<input
type="date"
value={newTaskDueDate}
onChange={(e) => setNewTaskDueDate(e.target.value)}
className="date-input"
/>
</div>
</form>
{/* Filters */}
<div className="tasks-filters">
<button
className={`filter-btn ${statusFilter === 'active' ? 'active' : ''}`}
onClick={() => setStatusFilter('active')}
>
Active
</button>
<button
className={`filter-btn ${statusFilter === 'completed' ? 'active' : ''}`}
onClick={() => setStatusFilter('completed')}
>
Completed
</button>
<button
className={`filter-btn ${statusFilter === 'all' ? 'active' : ''}`}
onClick={() => setStatusFilter('all')}
>
All
</button>
</div>
{/* Error */}
{error && (
<div className="tasks-error">
<p>{error}</p>
<button onClick={loadTasks}>Retry</button>
</div>
)}
{/* Loading */}
{loading && <div className="tasks-loading">Loading tasks...</div>}
{/* Task List */}
{!loading && tasks.length === 0 && (
<div className="tasks-empty">
{statusFilter === 'completed'
? 'No completed tasks yet'
: statusFilter === 'active'
? 'No active tasks. Add one above!'
: 'No tasks yet. Add one above!'}
</div>
)}
{!loading && tasks.length > 0 && (
<ul className="tasks-list">
{tasks.map(task => (
<li key={task.id} className={`task-item ${task.status === 'done' ? 'completed' : ''}`}>
<button
className={`task-checkbox ${task.status === 'done' ? 'checked' : ''}`}
onClick={() => task.status !== 'done' && handleComplete(task.id)}
title={task.status === 'done' ? 'Completed' : 'Mark as complete'}
disabled={task.status === 'done'}
>
{task.status === 'done' && <span className="checkmark">&#10003;</span>}
</button>
<div className="task-content">
{editingTask === task.id ? (
<div className="task-edit-form">
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="task-edit-input"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(task.id)
if (e.key === 'Escape') handleCancelEdit()
}}
/>
<button onClick={() => handleSaveEdit(task.id)} className="save-btn">Save</button>
<button onClick={handleCancelEdit} className="cancel-btn">Cancel</button>
</div>
) : (
<>
<span className={`task-title ${task.status === 'done' ? 'done' : ''}`}>
{task.task_title}
</span>
<div className="task-meta">
{task.priority && (
<span
className="task-priority"
style={{ backgroundColor: PRIORITY_COLORS[task.priority] }}
>
{task.priority}
</span>
)}
{task.due_date && (
<span className={`task-due ${formatDueDate(task.due_date).className}`}>
{formatDueDate(task.due_date).text}
</span>
)}
{task.status === 'in_progress' && (
<span className="task-status in-progress">In Progress</span>
)}
</div>
</>
)}
</div>
{editingTask !== task.id && (
<div className="task-actions">
{task.status !== 'done' && (
<button onClick={() => handleStartEdit(task)} className="edit-btn" title="Edit">
&#9998;
</button>
)}
<button onClick={() => handleDelete(task.id)} className="delete-btn" title="Delete">
&#10005;
</button>
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
)
}
export default Tasks

View File

@ -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;
}

View File

@ -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<TodoTask[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="todo-widget">
<div className="todo-widget-header">
<h3>Tasks</h3>
</div>
<div className="todo-widget-loading">Loading...</div>
</div>
)
}
if (error) {
return (
<div className="todo-widget">
<div className="todo-widget-header">
<h3>Tasks</h3>
</div>
<div className="todo-widget-error">{error}</div>
</div>
)
}
return (
<div className="todo-widget">
<div className="todo-widget-header">
<h3>Tasks</h3>
<span className="todo-count">{tasks.length}</span>
</div>
<form onSubmit={handleAddTask} className="todo-add-form">
<input
type="text"
placeholder="Add a task..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
disabled={isAdding}
className="todo-add-input"
/>
<button type="submit" disabled={isAdding || !newTaskTitle.trim()} className="todo-add-btn">
+
</button>
</form>
{tasks.length === 0 ? (
<div className="todo-widget-empty">
No pending tasks
</div>
) : (
<ul className="todo-list">
{tasks.map(task => (
<li key={task.id} className="todo-item">
<button
className="todo-checkbox"
onClick={() => handleComplete(task.id)}
title="Mark as complete"
>
<span className="checkmark"></span>
</button>
<div className="todo-content">
<span className="todo-title">{task.task_title}</span>
<div className="todo-meta">
{task.priority && (
<span
className="todo-priority"
style={{ backgroundColor: PRIORITY_COLORS[task.priority] }}
>
{task.priority}
</span>
)}
{task.due_date && (
<span className={`todo-due ${formatDueDate(task.due_date).className}`}>
{formatDueDate(task.due_date).text}
</span>
)}
</div>
</div>
</li>
))}
</ul>
)}
<div className="todo-widget-footer">
<Link to="/ui/tasks" className="view-all-link">View all tasks</Link>
</div>
</div>
)
}
export default TodoWidget

View File

@ -0,0 +1 @@
export { default } from './Tasks'

View File

@ -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<TaskResponse> => {
const syncEmailAccount = useCallback(async (id: number): Promise<CeleryTaskResponse> => {
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<TaskResponse> => {
const syncArticleFeed = useCallback(async (id: number): Promise<CeleryTaskResponse> => {
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<TaskResponse> => {
const syncGithubRepo = useCallback(async (accountId: number, repoId: number, forceFull = false): Promise<CeleryTaskResponse> => {
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<TaskResponse> => {
const syncGoogleFolder = useCallback(async (accountId: number, folderId: number, forceFull = false): Promise<CeleryTaskResponse> => {
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<TaskResponse> => {
const syncCalendarAccount = useCallback(async (id: number, forceFull = false): Promise<CeleryTaskResponse> => {
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<TodoTask[]> => {
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<TodoTask> => {
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<TodoTask> => {
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<void> => {
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<TodoTask> => {
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,
}
}

View File

@ -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',
},
},
})

View File

@ -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)

View File

@ -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

179
src/memory/api/tasks.py Normal file
View File

@ -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))

130
src/memory/common/tasks.py Normal file
View File

@ -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

View File

@ -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