mirror of
https://github.com/mruwnik/memory.git
synced 2026-01-02 09:12:58 +01:00
todo items
This commit is contained in:
parent
53f97485c2
commit
28d4be718c
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
426
frontend/src/components/todo/Tasks.css
Normal file
426
frontend/src/components/todo/Tasks.css
Normal 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;
|
||||
}
|
||||
}
|
||||
332
frontend/src/components/todo/Tasks.tsx
Normal file
332
frontend/src/components/todo/Tasks.tsx
Normal 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">✓</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">
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleDelete(task.id)} className="delete-btn" title="Delete">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tasks
|
||||
199
frontend/src/components/todo/TodoWidget.css
Normal file
199
frontend/src/components/todo/TodoWidget.css
Normal 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;
|
||||
}
|
||||
196
frontend/src/components/todo/TodoWidget.tsx
Normal file
196
frontend/src/components/todo/TodoWidget.tsx
Normal 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
|
||||
1
frontend/src/components/todo/index.ts
Normal file
1
frontend/src/components/todo/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Tasks'
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
179
src/memory/api/tasks.py
Normal 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
130
src/memory/common/tasks.py
Normal 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
|
||||
249
tests/memory/common/test_tasks.py
Normal file
249
tests/memory/common/test_tasks.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user