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:

Image: Final Task Manager app showing list view with tasks

Step 1: Create the App

Generate the app scaffold:

cd sdk
make app name=tasks

This creates the app structure:

tasks/ ├── __init__.py ├── __manifest__.py ├── module.py ├── controllers/ │ └── main.py ├── models/ │ └── __init__.py ├── views/ │ ├── templates/ │ │ └── tasks/ │ │ ├── desktop/ │ │ │ └── index.html │ │ └── mobile/ │ └── assets/ │ └── css/ └── lang/ └── en.json

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

  1. Start sparQ: make run
  2. Open your browser to http://localhost:8000/m/{mappid}/tasks (check your manifest for the actual mappid)
  3. Create a few tasks with different categories
  4. Try the filters
  5. Click the circle icon to toggle completion
  6. Edit and delete tasks
Image: Task Manager app in action

What You've Built

Congratulations! You've built a complete sparQ app with:

Next Steps

Want to extend your Task Manager? Try adding:

Ready to share your app? Head to Publishing to Marketplace.