A focused task tracker for small digital agencies. One screen, low-friction capture, inline status updates. Built with Django + HTMX + Alpine.js + PostgreSQL, run with Docker.
Layla runs a 3-person digital agency for ~20 clients. Requests arrive in Slack and email all day. Things fall through the cracks; clients re-ask about already-handled work; the team context-switches because there's no single place to look. This app is that single place.
cp .env.example .env
docker compose up --buildIn a second terminal:
docker compose run --rm web python manage.py migrate
docker compose run --rm web python manage.py seed_demoOpen http://localhost:8000 and sign in:
| Username | Password | Role |
|---|---|---|
layla |
demo1234 |
Owner |
sam |
demo1234 |
Member |
jamie |
demo1234 |
Member |
is_superuser=True is treated as the agency owner. The model is:
| Action | Owner | Member (own task) | Member (others' task) |
|---|---|---|---|
| View | yes | yes | yes |
| Create | yes (any assignee) | yes (forced to self) | — |
| Edit | yes | yes (no assignee change) | 403 |
| Status change / drag | yes | yes | 403 |
| Delete | yes | yes | 403 |
| Reassign | yes | no (field hidden) | no (field hidden) |
Defense in depth: action buttons are hidden in the template, drag-and-drop is filtered server-side via SortableJS filter, and every mutation view checks _can_modify(user, task) before touching the model.
At three users, is_superuser is a fine proxy for the owner role; a real multi-agency product would warrant a dedicated Role enum or custom user model.
Owners (Layla) get a Manage link in the header → http://localhost:8000/manage/ — an in-app page to add and edit team members and clients without leaving the product UI. The Django admin (/admin/) is still there as a fallback.
- Django 5 — backend + server-rendered templates
- HTMX — dynamic interactions without an SPA
- Alpine.js — ephemeral client state (modal, delete-confirm step, toast queue)
- SortableJS — drag-and-drop between status columns
- Tailwind CSS (CDN) — styling, no build step
- PostgreSQL 16 — persistence
- Docker Compose — local dev orchestration
┌─────────────────────────────────────────────────────────┐
│ Browser │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ Alpine │ │ HTMX │ │
│ │ - modal open? │ │ - filters → re-render │ │
│ │ - delete confirm │ │ - status arrows / drag │ │
│ │ - toast queue │ │ - form post → swap+toast │ │
│ └──────────────────┘ └──────────────────────────┘ │
└──────────────────────────────┬──────────────────────────┘
│ HTTP (HTML responses only)
▼
┌─────────────────────────────────────────────────────────┐
│ Django │
│ - views render full pages OR HTML partials │
│ - HX-Trigger headers fire toast / close-modal events │
│ - HX-Retarget redirects swap on form success │
│ - request.htmx + HX-Current-URL keep filters sticky │
└──────────────────────────────┬──────────────────────────┘
│
▼
┌──────────────┐
│ PostgreSQL │
└──────────────┘
State contract: Postgres owns persistent truth. Django renders partials. Alpine owns ephemeral UI (modal open/closed, delete-confirm step, toast queue). No JSON endpoints; everything is HTML.
src/
├── config/ # settings, urls, wsgi
├── core/ # base template, login template
├── clients/ # Client model + admin
└── tasks/ # Task model, views, forms, board templates,
│ # seed_demo, smoke tests
├── models.py # Task, Status/Priority/Source choices, save() override
├── views.py # board + 4 mutation views; HTMX partial responses
├── forms.py # TaskForm with Tailwind widget classes
├── templates/tasks/
│ ├── board.html # full-page kanban + filter strip + search
│ ├── _columns.html # 4-column container (HTMX swap target)
│ ├── _task_card.html # one card; arrows + drag handle + edit/delete
│ ├── _task_form.html # modal body for create + edit
│ └── _empty.html # column empty state
└── management/commands/seed_demo.py # idempotent demo data
docker compose run --rm web python manage.py testFive tests in src/tasks/tests.py:
- One end-to-end smoke flow: render board → create task → advance status via arrow → drop directly to Done via the drag pattern. Asserts response status,
HX-TriggerJSON,HX-Retargetheader, and resulting model state. - Four focused tests on
Task.save()'scompleted_atlifecycle (the most subtle correctness property in this codebase): set on transition into Done, cleared on transition out, untouched when re-saving an already-Done task, set on creation when starting Done.
See the PR description for the full rundown. Short version:
- Manual capture only — no Slack/email ingestion in MVP. Huge surface area; manual capture validates the workflow first.
- One board, not per-client/per-assignee views — Layla's pain is "no single place." Filters narrow it; nothing replaces it.
sourcefield on every task (Slack/Email/Meeting/Other) — preserves where requests came from.- Done column = last 7 days only — addresses "clients follow up on already-handled" without becoming a graveyard.
Task.save()setscompleted_atonly on transition into Done; clears on transition out — uses a DB-fetchedold_statusfor simplicity at this scale.- Server-rendered HTML over SPA — right tool for 3 users on the prescribed stack.
- Slack/email ingestion
- Notifications, daily digests, real-time push
- Comments thread, attachments, time tracking, recurring tasks
- Custom user model, multi-tenant
- CI configuration, Tailwind build pipeline, themed admin
Each is a "yes, eventually" — none is on the critical path to "stop dropping work."
docker compose exec db psql -U agency_tasks -d agency_tasks -c "TRUNCATE tasks_task RESTART IDENTITY;"
docker compose run --rm web python manage.py seed_demoOr full wipe (recreates the volume):
docker compose down -v
docker compose up --build
docker compose run --rm web python manage.py migrate
docker compose run --rm web python manage.py seed_demo