Build a Task Manager
Chapter 11: Build a complete CRUD application from scratch.
Now that you've learned the core concepts, let's put it all together by building a real app: a Task Manager with categories, due dates, and status tracking. This tutorial ties together everything from the previous chapters.
Prerequisites: Complete the Hello sparQ tutorial and read through the Core Concepts chapters first.
What We're Building
Our Task Manager will have:
- Tasks with title, description, due date, and status
- Categories to organize tasks
- List view with filtering
- Create, edit, and delete functionality
- Mark tasks as complete with HTMX
Step 1: Create the App
Generate the app scaffold:
cd sdk
make app name=tasks
This creates the app structure:
Update the manifest:
# data/modules/apps/tasks/__manifest__.py
manifest = {
'name': 'Task Manager',
'version': '1.0.0',
'mappid': 'x7k2m9', # Auto-generated by SDK
'main_route': '/tasks',
'description': 'Track and manage your tasks',
'type': 'App',
'color': '#3B82F6',
'icon': 'check-square'
}
The mappid is auto-generated by the SDK. Your app will be accessible at /m/{mappid}/tasks.
Step 2: Create the Models
We need two models: Category and Task.
Category Model
# models/category.py
from system.db import db
class Category(db.Model):
__tablename__ = 'tasks_categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
color = db.Column(db.String(7), default='#6B7280')
# Relationship to tasks
tasks = db.relationship('Task', backref='category', lazy='dynamic')
@classmethod
def get_all(cls):
return cls.query.order_by(cls.name).all()
@classmethod
def create(cls, name, color='#6B7280'):
category = cls(name=name, color=color)
db.session.add(category)
db.session.commit()
return category
Task Model
# models/task.py
from datetime import datetime, date
from system.db import db
class Task(db.Model):
__tablename__ = 'tasks_tasks'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
due_date = db.Column(db.Date)
completed = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign key to category
category_id = db.Column(db.Integer, db.ForeignKey('tasks_categories.id'))
@classmethod
def get_all(cls, category_id=None, show_completed=True):
query = cls.query
if category_id:
query = query.filter_by(category_id=category_id)
if not show_completed:
query = query.filter_by(completed=False)
return query.order_by(cls.due_date.asc().nullslast(), cls.created_at.desc()).all()
@classmethod
def get_by_id(cls, id):
return cls.query.get(id)
@classmethod
def create(cls, title, description=None, due_date=None, category_id=None):
task = cls(
title=title,
description=description,
due_date=due_date,
category_id=category_id
)
db.session.add(task)
db.session.commit()
return task
def update(self, **kwargs):
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.commit()
return self
def toggle_completed(self):
self.completed = not self.completed
db.session.commit()
return self
def delete(self):
db.session.delete(self)
db.session.commit()
@property
def is_overdue(self):
if self.due_date and not self.completed:
return self.due_date < date.today()
return False
Step 3: Create the Controller
# controllers/main.py
from flask import Blueprint, request, redirect, url_for, flash
from system.device.template import render_device_template
from ..models.task import Task
from ..models.category import Category
bp = Blueprint('main', __name__, url_prefix='/tasks')
@bp.route('/')
def index():
"""List all tasks."""
category_id = request.args.get('category', type=int)
show_completed = request.args.get('completed', 'true') == 'true'
tasks = Task.get_all(category_id=category_id, show_completed=show_completed)
categories = Category.get_all()
return render_device_template('tasks/desktop/index.html',
tasks=tasks,
categories=categories,
current_category=category_id,
show_completed=show_completed
)
@bp.route('/new', methods=['GET', 'POST'])
def create():
"""Create a new task."""
if request.method == 'POST':
due_date = request.form.get('due_date')
if due_date:
from datetime import datetime
due_date = datetime.strptime(due_date, '%Y-%m-%d').date()
Task.create(
title=request.form.get('title'),
description=request.form.get('description'),
due_date=due_date,
category_id=request.form.get('category_id') or None
)
flash('Task created!', 'success')
return redirect(url_for('tasks.main.index'))
categories = Category.get_all()
return render_device_template('tasks/desktop/form.html', categories=categories)
@bp.route('/')
def show(id):
"""Show task details."""
task = Task.get_by_id(id)
if not task:
flash('Task not found', 'error')
return redirect(url_for('tasks.main.index'))
return render_device_template('tasks/desktop/show.html', task=task)
@bp.route('//edit', methods=['GET', 'POST'])
def edit(id):
"""Edit a task."""
task = Task.get_by_id(id)
if not task:
flash('Task not found', 'error')
return redirect(url_for('tasks.main.index'))
if request.method == 'POST':
due_date = request.form.get('due_date')
if due_date:
from datetime import datetime
due_date = datetime.strptime(due_date, '%Y-%m-%d').date()
task.update(
title=request.form.get('title'),
description=request.form.get('description'),
due_date=due_date,
category_id=request.form.get('category_id') or None
)
flash('Task updated!', 'success')
return redirect(url_for('tasks.main.show', id=id))
categories = Category.get_all()
return render_device_template('tasks/desktop/form.html', task=task, categories=categories)
@bp.route('//delete', methods=['POST'])
def delete(id):
"""Delete a task."""
task = Task.get_by_id(id)
if task:
task.delete()
flash('Task deleted!', 'success')
return redirect(url_for('tasks.main.index'))
@bp.route('//toggle', methods=['POST'])
def toggle(id):
"""Toggle task completion (HTMX endpoint)."""
task = Task.get_by_id(id)
if task:
task.toggle_completed()
return render_device_template('tasks/desktop/partials/task_row.html', task=task)
Step 4: Create the Views
List View (index.html)
<!-- views/templates/tasks/desktop/index.html -->
{% extends "core/desktop/base.html" %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>{{ _("Tasks") }}</h1>
<a href="{{ url_for('tasks.main.create') }}" class="btn btn-primary">
{{ _("Add Task") }}
</a>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="get" class="d-flex gap-3">
<select name="category" class="form-select" style="width: auto;">
<option value="">All Categories</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if cat.id == current_category %}selected{% endif %}>
{{ cat.name }}
</option>
{% endfor %}
</select>
<div class="form-check form-switch d-flex align-items-center">
<input type="checkbox" name="completed" value="true"
class="form-check-input" id="showCompleted"
{% if show_completed %}checked{% endif %}>
<label class="form-check-label ms-2" for="showCompleted">
Show completed
</label>
</div>
<button type="submit" class="btn btn-outline-secondary">Filter</button>
</form>
</div>
</div>
<!-- Task List -->
<div class="card">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 40px"></th>
<th>Task</th>
<th>Category</th>
<th>Due Date</th>
<th style="width: 100px"></th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
{% include "tasks/desktop/partials/task_row.html" %}
{% else %}
<tr>
<td colspan="5" class="text-center text-muted py-4">
No tasks yet. <a href="{{ url_for('tasks.main.create') }}">Create one!</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
Task Row Partial (for HTMX)
<!-- views/templates/tasks/desktop/partials/task_row.html -->
<tr id="task-{{ task.id }}" class="{% if task.completed %}text-muted{% endif %}">
<td>
<button hx-post="{{ url_for('tasks.main.toggle', id=task.id) }}"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML"
class="btn btn-sm p-0 border-0">
{% if task.completed %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-circle"></i>
{% endif %}
</button>
</td>
<td>
<a href="{{ url_for('tasks.main.show', id=task.id) }}"
class="{% if task.completed %}text-decoration-line-through{% endif %}">
{{ task.title }}
</a>
</td>
<td>
{% if task.category %}
<span class="badge" style="background-color: {{ task.category.color }}">
{{ task.category.name }}
</span>
{% endif %}
</td>
<td>
{% if task.due_date %}
<span class="{% if task.is_overdue %}text-danger fw-bold{% endif %}">
{{ task.due_date.strftime('%b %d, %Y') }}
</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('tasks.main.edit', id=task.id) }}"
class="btn btn-sm btn-outline-secondary">Edit</a>
</td>
</tr>
Form View (form.html)
<!-- views/templates/tasks/desktop/form.html -->
{% extends "core/desktop/base.html" %}
{% block content %}
<div class="container py-4" style="max-width: 600px">
<h1>{{ _("Edit Task") if task else _("New Task") }}</h1>
<form method="post" class="card">
<div class="card-body">
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" name="title" id="title" class="form-control"
value="{{ task.title if task else '' }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea name="description" id="description" rows="3"
class="form-control">{{ task.description if task else '' }}</textarea>
</div>
<div class="row mb-3">
<div class="col">
<label for="category_id" class="form-label">Category</label>
<select name="category_id" id="category_id" class="form-select">
<option value="">None</option>
{% for cat in categories %}
<option value="{{ cat.id }}"
{% if task and task.category_id == cat.id %}selected{% endif %}>
{{ cat.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col">
<label for="due_date" class="form-label">Due Date</label>
<input type="date" name="due_date" id="due_date" class="form-control"
value="{{ task.due_date.strftime('%Y-%m-%d') if task and task.due_date else '' }}">
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<a href="{{ url_for('tasks.main.index') }}" class="btn btn-outline-secondary">
{{ _("Cancel") }}
</a>
<button type="submit" class="btn btn-primary">
{{ _("Save") }}
</button>
</div>
</form>
</div>
{% endblock %}
Detail View (show.html)
<!-- views/templates/tasks/desktop/show.html -->
{% extends "core/desktop/base.html" %}
{% block content %}
<div class="container py-4" style="max-width: 600px">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="{% if task.completed %}text-muted text-decoration-line-through{% endif %}">
{{ task.title }}
</span>
{% if task.category %}
<span class="badge" style="background-color: {{ task.category.color }}">
{{ task.category.name }}
</span>
{% endif %}
</div>
<div class="card-body">
{% if task.description %}
<p>{{ task.description }}</p>
{% endif %}
<div class="text-muted small">
{% if task.due_date %}
<p class="mb-1 {% if task.is_overdue %}text-danger{% endif %}">
<strong>Due:</strong> {{ task.due_date.strftime('%B %d, %Y') }}
</p>
{% endif %}
<p class="mb-0">
<strong>Created:</strong> {{ task.created_at.strftime('%B %d, %Y') }}
</p>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<a href="{{ url_for('tasks.main.index') }}" class="btn btn-outline-secondary">
← Back
</a>
<div class="d-flex gap-2">
<a href="{{ url_for('tasks.main.edit', id=task.id) }}"
class="btn btn-outline-primary">Edit</a>
<form method="post" action="{{ url_for('tasks.main.delete', id=task.id) }}"
onsubmit="return confirm('Delete this task?')">
<button type="submit" class="btn btn-outline-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
Step 5: Seed Default Categories
# module.py
def init_database(db):
"""Seed default categories."""
from .models.category import Category
if Category.query.count() == 0:
defaults = [
('Work', '#3B82F6'),
('Personal', '#10B981'),
('Shopping', '#F59E0B'),
('Health', '#EF4444')
]
for name, color in defaults:
Category.create(name=name, color=color)
Step 6: Add Translations
// lang/en.json
{
"Tasks": "Tasks",
"Add Task": "Add Task",
"New Task": "New Task",
"Edit Task": "Edit Task",
"Save": "Save",
"Cancel": "Cancel",
"Task created!": "Task created!",
"Task updated!": "Task updated!",
"Task deleted!": "Task deleted!"
}
Step 7: Test Your App
- Start sparQ:
make run - Open your browser to
http://localhost:8000/m/{mappid}/tasks(check your manifest for the actual mappid) - Create a few tasks with different categories
- Try the filters
- Click the circle icon to toggle completion
- Edit and delete tasks
What You've Built
Congratulations! You've built a complete sparQ app with:
- Two related database models
- Full CRUD operations
- HTMX-powered interactions
- Filtering and status management
- Translation support
- Default data seeding
Next Steps
Want to extend your Task Manager? Try adding:
- Priority levels (High, Medium, Low)
- Subtasks
- Due date reminders
- Export to CSV
- Search functionality
Ready to share your app? Head to Publishing to Marketplace.