../projects

Explanation

TaskBoard: A Receipt-Printing Kanban

2026-07-09

FastAPISQLModelSQLitePostgreSQLTypeScriptReactTauriExpoAWS LambdaTerraform

TaskBoard is a to-do board that prints. Add a task or move it forward, and a paper receipt curls out of a thermal printer on my desk, the same kind that fires order chits down the line in a restaurant kitchen. A kitchen calls that slip a chit, which is where the hosted version gets its name: it lives at printchit.app.

I built it because a to-do list on a screen does not work for the way my brain works. A task I cannot see is a task that quietly stops existing; an app I can swipe away takes the reminder with it. A printed chit behaves differently. It is physical, so it stays in my line of sight instead of behind a home screen. It shows the task and how far along it is, so remembering becomes the paper’s job and not mine. And moving a card into the done column, watching a COMPLETED receipt print, then crumpling the slip, lands a small hit of dopamine at the exact moment the work is finished.

Underneath the paper, it is an ordinary Kanban board: columns, cards, progress, comments. The interesting engineering is everything that has to be true for a slip of paper to reliably appear the instant you move a card, on whatever device you happen to be holding. This page explains what it is made of and why it is shaped this way. For the exact HTTP contract, see the API reference; for how a state change becomes a printed receipt, see the printer-integration explanation.

The idea

I also wanted a portfolio project that was real on every screen I actually use, a desktop window, a phone app, a browser tab, and that did something a screenshot never could. The printer made a good forcing function because it is stubborn: it is physical, it lives on exactly one machine, and it does not care how clever the app looks. Getting a task to reliably become a printed chit, whether I tap my phone on the couch or from a coffee shop across town, forced me to be honest about the hard parts: where the real copy of your data lives, how to keep printing from ever breaking the app, and how to reach a gadget in my apartment from a phone on cellular without leaving the front door open to the whole internet.

What I built

One backend of record, several thin clients, and a physical side effect.

        Desktop (Tauri + React)  ─┐
        Mobile (Expo / RN)       ─┼── HTTP/JSON ──►  FastAPI backend  ── SQLite
        Any browser              ─┘                  (source of truth)    │
                                                          │               │
   packages/shared (TS types + typed client) ─┘          │ on create/move │
                                                          ▼               │
                                                   RP850 thermal printer  │
                                                   (python-escpos)  ◄─────┘
  • api/ (FastAPI + SQLModel + SQLite). The single source of truth. It owns the data model, exposes a REST API, and is the only component that talks to the printer. SQLite because the data is small, local, and single-writer: a file-backed database is the right amount of database for a personal board. The same process can also serve the built web UI, so a browser is a first-class client at the API’s own address.
  • packages/shared/ (one TypeScript package). The API’s response shapes and a typed fetch client live here once and are imported by both native clients. One contract, two UIs.
  • desktop/ (Tauri + React). A native window (Rust shell) around a React board with drag-and-drop between columns. The same React build is what the API serves to browsers.
  • mobile/ (Expo / React Native). A phone companion that reaches the API over the LAN or a private Tailscale mesh, in Expo Go, or as an installable Android build.

The domain is deliberately plain: a board is a subject; its swimlanes are the status columns (Pending, In Progress, Complete); a task lives in exactly one swimlane and carries progress and comments. Moving a task to a different swimlane is the status change that prints.

Key decisions and trade-offs

The through-line: keep the source of truth in one place, treat the printer as a side effect rather than a feature with its own API, and let the same code run either fully local or in the cloud.

  • The backend is the source of truth. Both clients are thin: they render server state and send intentions (“move task 12 to In Progress”). They never own data. This is what keeps two different UIs consistent, and it is what makes printing reliable, because the print decision lives next to the printer, not in a UI that might be closed. The cost is a round trip for every change; for a personal board that is a non-issue.
  • Printing happens as a side effect of a state change, so there is no “print this” call anywhere in the API. The create and move routes fire the receipt after the change is saved, so a receipt always reflects something that actually happened. Printing runs best-effort and never blocks or fails the response: an offline printer costs a log line, not a failed task move. The mechanism is the subject of its own write-up.
  • Desktop and mobile are both React/TypeScript, so the types and API client live once in packages/shared. Writing them by hand (rather than generating them) keeps the toolchain trivial for a project this size; the cost is discipline, so CI type-checks both clients and Pydantic is the runtime guard at the boundary.
  • I picked Tauri over Electron and Expo over Flutter. Tauri gives a smaller, lighter native window while still wrapping an ordinary React app, and the UI runs in a plain browser during development so day-to-day work needs no Rust. Electron would have bundled a full Chromium per app (heavier, but a single well-trodden runtime); Flutter would have bought one polished UI toolkit across desktop and mobile, at the cost of a second language and a separate API client. Both were rejected for the same reason: Expo shares the language, types, and API client with the desktop app, so choosing it is choosing one skill set and maximum reuse over a broader-but-divergent stack.
  • Because the API lives where the printer is, “use it from anywhere” means reaching home, not moving to the cloud. A private Tailscale mesh gives the phone an encrypted path to the PC with no ports opened to the internet.

Two ways to run it

The same app runs in two shapes, chosen by a handful of settings.

Local-first (the default). Everything runs on the one Windows PC the printer is plugged into, storing tasks in a single SQLite file. There is no login, and printing is immediate: creating or moving a task prints right then, in the background. It is the whole system in one process, and it runs on any machine even with no printer attached, because a console mode just draws the receipt in the log instead of on paper.

Cloud-hosted. The same app also runs with no home PC in the loop, and that version has one genuinely weird problem to solve: a server in a data center cannot reach a printer sitting in my apartment. Everything else is standard cloud plumbing; that constraint is what makes it interesting.

The app is packaged to run on AWS Lambda (a small adapter, Mangum, lets the same Python code answer web requests there), behind a gateway that checks a login token with Cognito before any request reaches the code. The data moves from the local SQLite file to a managed Postgres database (Aurora Serverless) that sleeps when no one is using it, because Lambda spins up many short-lived copies of the app and they need one shared database rather than a file on a disk that no longer exists. The web board is served over HTTPS from S3 and CloudFront at its own domain (a TLS certificate from ACM, DNS on Route 53), and the browser is only allowed to call the API from that one known address. The whole stack is defined in code with Terraform so it can be torn down and rebuilt on demand.

Which brings us back to the printer in my apartment. The cloud server cannot reach it, so it does not try. Instead of printing directly, the create and move actions drop a “print this” ticket into the database. A small print-agent running on my home PC checks in every so often, asks whether anything is waiting, prints whatever it finds on the RP850, and marks it done, proving who it is with a service token. The cloud never touches the hardware; the hardware reaches out to the cloud and pulls its work.

Both modes share one receipt formatter, so a chit looks the same whether it printed inline on the PC or was pulled from the cloud by the agent.

The code, by version

TaskBoard grew in stages, and each milestone is its own public repository, so the progression is easy to read as a series:

  • v1, desktop: the Tauri desktop app, the FastAPI/SQLite backend, and the printer. taskboard-desktop
  • v2, mobile + Tailscale: adds the browser-served board, a responsive UI, an Expo mobile app (Expo Go), and remote access over Tailscale. taskboard-mobile-tailscale
  • v3, Android: adds a standalone Android app built with EAS Build. taskboard-android

The cloud edition (AWS Lambda, Aurora Serverless, Cognito, CloudFront, and invite-only multi-tenant auth) builds on v3 and lives in a private repository.

See also