a web app for chore tracking
OCaml 93%
4%
Dune 2%
Nix <1%

README.md

Recurring Tasks #

A small "things to do regularly" tracker: each task has a name, an interval in days, and an optional category. The list is sorted by urgency (overdue first), with categories grouped as sections.

Backend: Dream (OCaml) + SQLite. Frontend: server-rendered HTML + Datastar for reactivity — no JS build step, no client framework.

Project layout #

dune-project
bin/
  dune
  main.ml          -- entry point
lib/
  dune
  db.ml            -- SQLite schema + CRUD
  status.ml        -- "due in N days" / "overdue" calculations
  render.ml        -- HTML templates (full page + fragments)
  sse.ml           -- Datastar SSE helpers (patch-elements/patch-signals)
  handlers.ml      -- HTTP route handlers
  app.ml           -- router + Dream.run

Setup #

You'll need OCaml + opam. Then:

opam install dream sqlite3 yojson lwt_ppx
dune build
dune exec bin/main.exe

The app listens on http://localhost:8080 and creates recurring_todo.db (SQLite) in the working directory on first run.

How it works #

  • GET / renders the full page: the "add task" / "add category" form plus a <div id="task-list"> containing the current tasks, grouped by category and sorted by due date.
  • Each "Done", "Delete", "Add task" and "Add category" button is wired up with data-on:click="@post(...)" / @delete(...). Datastar sends the current signal store (the form's bound inputs) as JSON in the request body.
  • Every handler responds with text/event-stream and sends a datastar-patch-elements event containing a freshly-rendered <div id="task-list">...</div>. Since the response element has the same id as the one already on the page, Datastar morphs it in place — re-sorted, re-grouped, with the new/updated/removed task reflected.
  • Adding a task or category also sends a datastar-patch-signals event to clear the relevant input fields, and adding a category re-sends the <select id="category-select"> so the new option appears immediately.

Data model #

  • categories(id, name)
  • tasks(id, name, category_id, interval_days, last_done, created_at)

last_done is NULL until a task is completed for the first time, in which case the "due" clock starts from created_at. Marking a task done sets last_done to the current time; "due again" = last_done + interval_days.

Urgency buckets (see lib/status.ml):

  • Overdue — past its due date (red)
  • Due soon — due today, tomorrow, or within 3 days (amber)
  • Upcoming — everything else (green)

Ideas for extending this #

  • Edit tasks: add a PUT /tasks/:id route and an inline edit form (toggle visibility with a signal, e.g. data-show="$editing123").
  • Snooze: a /tasks/:id/snooze endpoint that pushes last_done forward by a day or two without marking the task fully "done".
  • History: a task_completions table logging every "done" event, to show streaks or a completion log per task.
  • Auth/multi-user: add a users table and a user_id column on tasks, plus Dream's session middleware.
  • Live updates across devices: keep the SSE connection open (instead of one-shot per action) and push datastar-patch-elements whenever any client changes data — useful if multiple people share a household task list.