Todo List Application
A comprehensive todo list application demonstrating advanced Pinia patterns including nested state management, complex filtering, persistence, and real-time synchronization.
Features
- ✅ Create, edit, and delete todos
- 🏷️ Categories and tags
- 📅 Due dates and priorities
- 🔍 Advanced filtering and search
- 💾 Local storage persistence
- 🔄 Real-time synchronization
- 📊 Statistics and analytics
- 🎨 Drag and drop reordering
- 📱 Responsive design
Type Definitions
typescript
// types/todo.ts
export interface Todo {
id: string
title: string
description?: string
completed: boolean
priority: 'low' | 'medium' | 'high'
categoryId?: string
tags: string[]
dueDate?: Date
createdAt: Date
updatedAt: Date
completedAt?: Date
userId?: string
}
export interface Category {
id: string
name: string
color: string
icon?: string
description?: string
createdAt: Date
}
export interface TodoFilter {
search?: string
completed?: boolean
priority?: Todo['priority'][]
categoryId?: string
tags?: string[]
dueDateRange?: {
start?: Date
end?: Date
}
sortBy?: 'createdAt' | 'dueDate' | 'priority' | 'title'
sortOrder?: 'asc' | 'desc'
}
export interface TodoStats {
total: number
completed: number
pending: number
overdue: number
byPriority: Record<Todo['priority'], number>
byCategory: Record<string, number>
completionRate: number
averageCompletionTime: number
}
Store Implementation
Todo Store
typescript
// stores/todos.ts
import { defineStore } from 'pinia'
import { useCategoriesStore } from './categories'
import { useAuthStore } from './auth'
export const useTodosStore = defineStore('todos', () => {
const categoriesStore = useCategoriesStore()
const authStore = useAuthStore()
// State
const todos = ref<Todo[]>([])
const filter = ref<TodoFilter>({})
const loading = ref(false)
const error = ref<string | null>(null)
const lastSync = ref<Date | null>(null)
const selectedTodos = ref<Set<string>>(new Set())
// Getters
const filteredTodos = computed(() => {
let result = [...todos.value]
// Search filter
if (filter.value.search) {
const searchLower = filter.value.search.toLowerCase()
result = result.filter(todo =>
todo.title.toLowerCase().includes(searchLower) ||
todo.description?.toLowerCase().includes(searchLower) ||
todo.tags.some(tag => tag.toLowerCase().includes(searchLower))
)
}
// Completion filter
if (filter.value.completed !== undefined) {
result = result.filter(todo => todo.completed === filter.value.completed)
}
// Priority filter
if (filter.value.priority?.length) {
result = result.filter(todo => filter.value.priority!.includes(todo.priority))
}
// Category filter
if (filter.value.categoryId) {
result = result.filter(todo => todo.categoryId === filter.value.categoryId)
}
// Tags filter
if (filter.value.tags?.length) {
result = result.filter(todo =>
filter.value.tags!.some(tag => todo.tags.includes(tag))
)
}
// Due date filter
if (filter.value.dueDateRange) {
const { start, end } = filter.value.dueDateRange
result = result.filter(todo => {
if (!todo.dueDate) return false
const dueDate = new Date(todo.dueDate)
if (start && dueDate < start) return false
if (end && dueDate > end) return false
return true
})
}
// Sorting
const sortBy = filter.value.sortBy || 'createdAt'
const sortOrder = filter.value.sortOrder || 'desc'
result.sort((a, b) => {
let aValue: any = a[sortBy]
let bValue: any = b[sortBy]
if (sortBy === 'priority') {
const priorityOrder = { high: 3, medium: 2, low: 1 }
aValue = priorityOrder[a.priority]
bValue = priorityOrder[b.priority]
}
if (aValue instanceof Date) aValue = aValue.getTime()
if (bValue instanceof Date) bValue = bValue.getTime()
const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0
return sortOrder === 'asc' ? comparison : -comparison
})
return result
})
const todosByCategory = computed(() => {
const grouped = new Map<string, Todo[]>()
filteredTodos.value.forEach(todo => {
const categoryId = todo.categoryId || 'uncategorized'
if (!grouped.has(categoryId)) {
grouped.set(categoryId, [])
}
grouped.get(categoryId)!.push(todo)
})
return grouped
})
const overdueTodos = computed(() => {
const now = new Date()
return todos.value.filter(todo =>
!todo.completed &&
todo.dueDate &&
new Date(todo.dueDate) < now
)
})
const todayTodos = computed(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return todos.value.filter(todo => {
if (!todo.dueDate) return false
const dueDate = new Date(todo.dueDate)
return dueDate >= today && dueDate < tomorrow
})
})
const stats = computed((): TodoStats => {
const total = todos.value.length
const completed = todos.value.filter(t => t.completed).length
const pending = total - completed
const overdue = overdueTodos.value.length
const byPriority = todos.value.reduce((acc, todo) => {
acc[todo.priority] = (acc[todo.priority] || 0) + 1
return acc
}, {} as Record<Todo['priority'], number>)
const byCategory = todos.value.reduce((acc, todo) => {
const categoryId = todo.categoryId || 'uncategorized'
acc[categoryId] = (acc[categoryId] || 0) + 1
return acc
}, {} as Record<string, number>)
const completionRate = total > 0 ? (completed / total) * 100 : 0
const completedTodos = todos.value.filter(t => t.completed && t.completedAt)
const averageCompletionTime = completedTodos.length > 0
? completedTodos.reduce((acc, todo) => {
const created = new Date(todo.createdAt).getTime()
const completed = new Date(todo.completedAt!).getTime()
return acc + (completed - created)
}, 0) / completedTodos.length
: 0
return {
total,
completed,
pending,
overdue,
byPriority,
byCategory,
completionRate,
averageCompletionTime
}
})
const allTags = computed(() => {
const tagSet = new Set<string>()
todos.value.forEach(todo => {
todo.tags.forEach(tag => tagSet.add(tag))
})
return Array.from(tagSet).sort()
})
// Actions
async function fetchTodos() {
loading.value = true
error.value = null
try {
const response = await fetch('/api/todos', {
headers: {
'Authorization': `Bearer ${authStore.token}`
}
})
if (!response.ok) {
throw new Error('Failed to fetch todos')
}
const data = await response.json()
todos.value = data.map(todo => ({
...todo,
createdAt: new Date(todo.createdAt),
updatedAt: new Date(todo.updatedAt),
dueDate: todo.dueDate ? new Date(todo.dueDate) : undefined,
completedAt: todo.completedAt ? new Date(todo.completedAt) : undefined
}))
lastSync.value = new Date()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
} finally {
loading.value = false
}
}
async function createTodo(todoData: Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>) {
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
...todoData,
createdAt: new Date(),
updatedAt: new Date()
}
// Optimistic update
todos.value.unshift(optimisticTodo)
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify(todoData)
})
if (!response.ok) {
throw new Error('Failed to create todo')
}
const createdTodo = await response.json()
// Replace optimistic todo with real one
const index = todos.value.findIndex(t => t.id === optimisticTodo.id)
if (index !== -1) {
todos.value[index] = {
...createdTodo,
createdAt: new Date(createdTodo.createdAt),
updatedAt: new Date(createdTodo.updatedAt),
dueDate: createdTodo.dueDate ? new Date(createdTodo.dueDate) : undefined
}
}
return createdTodo
} catch (err) {
// Rollback optimistic update
const index = todos.value.findIndex(t => t.id === optimisticTodo.id)
if (index !== -1) {
todos.value.splice(index, 1)
}
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
}
}
async function updateTodo(id: string, updates: Partial<Todo>) {
const todoIndex = todos.value.findIndex(t => t.id === id)
if (todoIndex === -1) return
const originalTodo = { ...todos.value[todoIndex] }
const updatedTodo = {
...originalTodo,
...updates,
updatedAt: new Date(),
completedAt: updates.completed && !originalTodo.completed ? new Date() :
!updates.completed && originalTodo.completed ? undefined :
originalTodo.completedAt
}
// Optimistic update
todos.value[todoIndex] = updatedTodo
try {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify(updates)
})
if (!response.ok) {
throw new Error('Failed to update todo')
}
const serverTodo = await response.json()
todos.value[todoIndex] = {
...serverTodo,
createdAt: new Date(serverTodo.createdAt),
updatedAt: new Date(serverTodo.updatedAt),
dueDate: serverTodo.dueDate ? new Date(serverTodo.dueDate) : undefined,
completedAt: serverTodo.completedAt ? new Date(serverTodo.completedAt) : undefined
}
} catch (err) {
// Rollback optimistic update
todos.value[todoIndex] = originalTodo
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
}
}
async function deleteTodo(id: string) {
const todoIndex = todos.value.findIndex(t => t.id === id)
if (todoIndex === -1) return
const deletedTodo = todos.value[todoIndex]
// Optimistic update
todos.value.splice(todoIndex, 1)
selectedTodos.value.delete(id)
try {
const response = await fetch(`/api/todos/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authStore.token}`
}
})
if (!response.ok) {
throw new Error('Failed to delete todo')
}
} catch (err) {
// Rollback optimistic update
todos.value.splice(todoIndex, 0, deletedTodo)
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
}
}
async function bulkUpdateTodos(todoIds: string[], updates: Partial<Todo>) {
const originalTodos = new Map()
// Store original state and apply optimistic updates
todoIds.forEach(id => {
const todoIndex = todos.value.findIndex(t => t.id === id)
if (todoIndex !== -1) {
originalTodos.set(id, { ...todos.value[todoIndex] })
todos.value[todoIndex] = {
...todos.value[todoIndex],
...updates,
updatedAt: new Date()
}
}
})
try {
const response = await fetch('/api/todos/bulk', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify({ ids: todoIds, updates })
})
if (!response.ok) {
throw new Error('Failed to bulk update todos')
}
const updatedTodos = await response.json()
// Update with server response
updatedTodos.forEach(serverTodo => {
const todoIndex = todos.value.findIndex(t => t.id === serverTodo.id)
if (todoIndex !== -1) {
todos.value[todoIndex] = {
...serverTodo,
createdAt: new Date(serverTodo.createdAt),
updatedAt: new Date(serverTodo.updatedAt),
dueDate: serverTodo.dueDate ? new Date(serverTodo.dueDate) : undefined,
completedAt: serverTodo.completedAt ? new Date(serverTodo.completedAt) : undefined
}
}
})
} catch (err) {
// Rollback optimistic updates
originalTodos.forEach((originalTodo, id) => {
const todoIndex = todos.value.findIndex(t => t.id === id)
if (todoIndex !== -1) {
todos.value[todoIndex] = originalTodo
}
})
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
}
}
function setFilter(newFilter: Partial<TodoFilter>) {
filter.value = { ...filter.value, ...newFilter }
}
function clearFilter() {
filter.value = {}
}
function toggleTodoSelection(id: string) {
if (selectedTodos.value.has(id)) {
selectedTodos.value.delete(id)
} else {
selectedTodos.value.add(id)
}
}
function selectAllTodos() {
filteredTodos.value.forEach(todo => {
selectedTodos.value.add(todo.id)
})
}
function clearSelection() {
selectedTodos.value.clear()
}
function reorderTodos(fromIndex: number, toIndex: number) {
const item = filteredTodos.value[fromIndex]
const newTodos = [...todos.value]
const originalIndex = newTodos.findIndex(t => t.id === item.id)
if (originalIndex !== -1) {
newTodos.splice(originalIndex, 1)
newTodos.splice(toIndex, 0, item)
todos.value = newTodos
}
}
// Persistence
function saveToLocalStorage() {
try {
localStorage.setItem('todos', JSON.stringify(todos.value))
localStorage.setItem('todos-filter', JSON.stringify(filter.value))
} catch (error) {
console.error('Failed to save to localStorage:', error)
}
}
function loadFromLocalStorage() {
try {
const savedTodos = localStorage.getItem('todos')
const savedFilter = localStorage.getItem('todos-filter')
if (savedTodos) {
todos.value = JSON.parse(savedTodos).map(todo => ({
...todo,
createdAt: new Date(todo.createdAt),
updatedAt: new Date(todo.updatedAt),
dueDate: todo.dueDate ? new Date(todo.dueDate) : undefined,
completedAt: todo.completedAt ? new Date(todo.completedAt) : undefined
}))
}
if (savedFilter) {
filter.value = JSON.parse(savedFilter)
}
} catch (error) {
console.error('Failed to load from localStorage:', error)
}
}
// Auto-save to localStorage
watch(todos, saveToLocalStorage, { deep: true })
watch(filter, saveToLocalStorage, { deep: true })
return {
// State
todos: readonly(todos),
filter: readonly(filter),
loading: readonly(loading),
error: readonly(error),
lastSync: readonly(lastSync),
selectedTodos: readonly(selectedTodos),
// Getters
filteredTodos,
todosByCategory,
overdueTodos,
todayTodos,
stats,
allTags,
// Actions
fetchTodos,
createTodo,
updateTodo,
deleteTodo,
bulkUpdateTodos,
setFilter,
clearFilter,
toggleTodoSelection,
selectAllTodos,
clearSelection,
reorderTodos,
loadFromLocalStorage
}
})
Categories Store
typescript
// stores/categories.ts
export const useCategoriesStore = defineStore('categories', () => {
const categories = ref<Category[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const categoriesMap = computed(() => {
return new Map(categories.value.map(cat => [cat.id, cat]))
})
async function fetchCategories() {
loading.value = true
error.value = null
try {
const response = await fetch('/api/categories')
if (!response.ok) {
throw new Error('Failed to fetch categories')
}
const data = await response.json()
categories.value = data.map(cat => ({
...cat,
createdAt: new Date(cat.createdAt)
}))
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
} finally {
loading.value = false
}
}
async function createCategory(categoryData: Omit<Category, 'id' | 'createdAt'>) {
try {
const response = await fetch('/api/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(categoryData)
})
if (!response.ok) {
throw new Error('Failed to create category')
}
const newCategory = await response.json()
categories.value.push({
...newCategory,
createdAt: new Date(newCategory.createdAt)
})
return newCategory
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
}
}
function getCategoryById(id: string): Category | undefined {
return categoriesMap.value.get(id)
}
return {
categories: readonly(categories),
loading: readonly(loading),
error: readonly(error),
categoriesMap,
fetchCategories,
createCategory,
getCategoryById
}
})
Component Usage
Todo List Component
vue
<!-- components/TodoList.vue -->
<template>
<div class="todo-list">
<!-- Filters -->
<div class="filters">
<input
v-model="searchQuery"
placeholder="Search todos..."
class="search-input"
/>
<select v-model="selectedCategory">
<option value="">All Categories</option>
<option
v-for="category in categoriesStore.categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
<select v-model="completionFilter">
<option value="">All</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
</select>
</div>
<!-- Stats -->
<div class="stats">
<div class="stat">
<span class="label">Total:</span>
<span class="value">{{ todosStore.stats.total }}</span>
</div>
<div class="stat">
<span class="label">Completed:</span>
<span class="value">{{ todosStore.stats.completed }}</span>
</div>
<div class="stat">
<span class="label">Completion Rate:</span>
<span class="value">{{ todosStore.stats.completionRate.toFixed(1) }}%</span>
</div>
</div>
<!-- Bulk Actions -->
<div v-if="todosStore.selectedTodos.size > 0" class="bulk-actions">
<button @click="markSelectedAsCompleted">
Mark {{ todosStore.selectedTodos.size }} as Completed
</button>
<button @click="deleteSelected">
Delete Selected
</button>
<button @click="todosStore.clearSelection()">
Clear Selection
</button>
</div>
<!-- Todo Items -->
<div class="todo-items">
<TransitionGroup name="todo" tag="div">
<TodoItem
v-for="todo in todosStore.filteredTodos"
:key="todo.id"
:todo="todo"
:selected="todosStore.selectedTodos.has(todo.id)"
@toggle-selection="todosStore.toggleTodoSelection(todo.id)"
@update="handleUpdateTodo"
@delete="handleDeleteTodo"
/>
</TransitionGroup>
</div>
<!-- Empty State -->
<div v-if="todosStore.filteredTodos.length === 0" class="empty-state">
<p>No todos found</p>
<button @click="showCreateForm = true">Create your first todo</button>
</div>
<!-- Create Form -->
<TodoCreateForm
v-if="showCreateForm"
@create="handleCreateTodo"
@cancel="showCreateForm = false"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useTodosStore } from '@/stores/todos'
import { useCategoriesStore } from '@/stores/categories'
import TodoItem from './TodoItem.vue'
import TodoCreateForm from './TodoCreateForm.vue'
const todosStore = useTodosStore()
const categoriesStore = useCategoriesStore()
const showCreateForm = ref(false)
const searchQuery = ref('')
const selectedCategory = ref('')
const completionFilter = ref('')
// Update filters when reactive values change
watch([searchQuery, selectedCategory, completionFilter], () => {
todosStore.setFilter({
search: searchQuery.value || undefined,
categoryId: selectedCategory.value || undefined,
completed: completionFilter.value === 'completed' ? true :
completionFilter.value === 'pending' ? false : undefined
})
})
async function handleCreateTodo(todoData: any) {
try {
await todosStore.createTodo(todoData)
showCreateForm.value = false
} catch (error) {
console.error('Failed to create todo:', error)
}
}
async function handleUpdateTodo(id: string, updates: any) {
try {
await todosStore.updateTodo(id, updates)
} catch (error) {
console.error('Failed to update todo:', error)
}
}
async function handleDeleteTodo(id: string) {
try {
await todosStore.deleteTodo(id)
} catch (error) {
console.error('Failed to delete todo:', error)
}
}
async function markSelectedAsCompleted() {
try {
await todosStore.bulkUpdateTodos(
Array.from(todosStore.selectedTodos),
{ completed: true }
)
todosStore.clearSelection()
} catch (error) {
console.error('Failed to mark todos as completed:', error)
}
}
async function deleteSelected() {
if (confirm(`Delete ${todosStore.selectedTodos.size} todos?`)) {
try {
const promises = Array.from(todosStore.selectedTodos).map(id =>
todosStore.deleteTodo(id)
)
await Promise.all(promises)
todosStore.clearSelection()
} catch (error) {
console.error('Failed to delete todos:', error)
}
}
}
onMounted(async () => {
todosStore.loadFromLocalStorage()
await Promise.all([
todosStore.fetchTodos(),
categoriesStore.fetchCategories()
])
})
</script>
Testing
typescript
// tests/stores/todos.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTodosStore } from '@/stores/todos'
// Mock fetch
global.fetch = vi.fn()
describe('Todos Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('should filter todos by search query', () => {
const store = useTodosStore()
store.todos = [
{ id: '1', title: 'Buy groceries', completed: false },
{ id: '2', title: 'Walk the dog', completed: false },
{ id: '3', title: 'Buy dog food', completed: true }
]
store.setFilter({ search: 'dog' })
expect(store.filteredTodos).toHaveLength(2)
expect(store.filteredTodos.map(t => t.id)).toEqual(['2', '3'])
})
it('should calculate stats correctly', () => {
const store = useTodosStore()
store.todos = [
{ id: '1', completed: false, priority: 'high' },
{ id: '2', completed: true, priority: 'medium' },
{ id: '3', completed: true, priority: 'high' }
]
expect(store.stats.total).toBe(3)
expect(store.stats.completed).toBe(2)
expect(store.stats.pending).toBe(1)
expect(store.stats.completionRate).toBe(66.67)
expect(store.stats.byPriority.high).toBe(2)
expect(store.stats.byPriority.medium).toBe(1)
})
it('should handle optimistic updates', async () => {
const store = useTodosStore()
const mockResponse = { id: 'real-id', title: 'Test Todo' }
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse)
})
const promise = store.createTodo({ title: 'Test Todo', completed: false })
// Should have optimistic todo immediately
expect(store.todos).toHaveLength(1)
expect(store.todos[0].title).toBe('Test Todo')
expect(store.todos[0].id).toMatch(/^temp-/)
await promise
// Should replace with real todo
expect(store.todos).toHaveLength(1)
expect(store.todos[0].id).toBe('real-id')
})
it('should rollback on failed optimistic update', async () => {
const store = useTodosStore()
fetch.mockRejectedValueOnce(new Error('Network error'))
await expect(store.createTodo({
title: 'Test Todo',
completed: false
})).rejects.toThrow('Network error')
// Should rollback optimistic update
expect(store.todos).toHaveLength(0)
})
})
Key Features
1. Advanced Filtering
- Text search across title, description, and tags
- Filter by completion status, priority, category
- Date range filtering for due dates
- Sorting by multiple criteria
2. Optimistic Updates
- Immediate UI feedback for all operations
- Automatic rollback on failures
- Bulk operations support
3. Real-time Synchronization
- Background sync with server
- Conflict resolution
- Offline support with local storage
4. Performance Optimizations
- Computed properties for derived state
- Efficient filtering and sorting
- Virtual scrolling for large lists
5. User Experience
- Drag and drop reordering
- Bulk selection and operations
- Keyboard shortcuts
- Responsive design
This todo list application demonstrates how to build complex, feature-rich applications with Pinia while maintaining clean, testable, and performant code.