A desktop time tracker and earnings ledger built with Tauri v2, React and TypeScript. You track time in sessions against tasks with hourly rates, reconcile it against work logs imported from DataAnnotation, and see what you've earned and when it pays out.
| Page | What it's for |
|---|---|
| Sessions | Pick a task and its timer starts; pause, resume and stop timers inside the session, and add timers for other tasks to it (each day's work gets its own session). Each timer's row shows its task, and live totals cover the active session. The list shows which tasks each session ran and their totals; it covers the last 30 days (older ones a click away) and filters by source. Add time logs a finished timer after the fact (a "Manual" session). |
| Tasks | Tasks with hourly rates and default note templates; start one, star favorites, archive tasks you're done with. Each task lists its newest entries and opens History for the rest. Time keeps the rate it was tracked at, so changing a rate only affects new time. |
| Notes | Free-form or template-based notes on a task (pick it on the Notes page), a session or a timer. A session's notes open from its row, which previews the newest one. Note templates have their own tab. |
| History | Every tracked, added and imported entry: quick ranges (remembered), search and task filters, totals and charts, the sortable entries table (edit, move an entry to another task, delete, CSV export; imported days are read-only, since each import rebuilds them), and the daily goal's results over the range. |
| Calendar | Earnings, hours and released amounts per day, and the month's totals; click a day for its tasks and releases. |
| Payouts | Import DataAnnotation work logs and see available, pending and upcoming releases, when the last import was, and a daily reconciliation grouped by month. An import's notice has Undo, which puts the ledger and the days it rewrote back as they were (refused once one of those days has changed). Project mappings lists which task each project goes to; moving a project moves its past imported days too. |
| Settings | Appearance, payouts (funds-release delay, days between withdrawals), idle detection and the long-run reminder, daily goal, backups (and restoring one), data export/import/exact restore/reset, privacy. |
The system tray (menu-bar item on macOS) shows the running or paused timer and can pause, resume or stop it, start a task's timer, or open the app. Its Start menu lists starred tasks first, then the ones you used most recently; Choose Task… opens the app's start dialog for any other task. Starting work behaves the same everywhere (tray, Sessions toolbar, the + button): with a session active, the task joins it, resuming its paused timer or starting a new one, and stopping whatever is running (a toast says so). Only End Session ends a session; a session left active from an earlier day gives way to a new one. Work starts from existing tasks; create new ones on the Tasks page. Closing the window hides TaskMint to the tray, where timers keep running; quit from the tray menu. On macOS, clicking the Dock icon brings the window back.
Earnings are rate × time everywhere, at the rate captured for each timer: its session's when it's on the task the session opened for, otherwise its own task's when the timer was created (imported days carry the rate the site's pay implies, so they match Payouts). With a daily goal set, the sidebar shows today's progress live, and notifications fire at 50%, 75% and 100% as you cross them.
Inactivity detection (off by default; Settings → Idle & Reminders) uses the system idle time, so work in any app counts. When there's been no keyboard or mouse input for the threshold, the running timer pauses as of when input stopped, so the away time isn't counted. When you're back, TaskMint comes to the front and asks whether to discard the away time and resume, keep it and resume, or stay paused; the tray offers the same choices. This works on macOS and Windows.
The long-run reminder (on by default, after 4 hours) sends a notification when a timer has run that long without a pause, for the timer you forgot to stop. It works with inactivity detection off.
TaskMint stores everything locally. Nothing is synced anywhere.
| Data | Store |
|---|---|
| Sessions and timer instances | sessions.json in the app data dir, owned by the Rust backend |
| Imported work logs and project→task mappings | worklogs.json in the app data dir, owned by the Rust backend |
| Tasks, notes, note templates, the daily goal and its history, withdrawals, and the payout settings (funds-release days, days between withdrawals) | appdata.json in the app data dir, owned by the Rust backend (see below for data from older versions) |
| Other settings and UI preferences (appearance, notifications, idle and reminders, backups) | The webview's localStorage |
App data dir on macOS: ~/Library/Application Support/com.taskmint.desktop/.
Before 2.1 the app identifier was com.taskmint.app: the first launch of a
newer version moves that data folder, the webview's storage (the settings)
and the server token's keychain item to the new identifier, and logs what it
moved. Nothing moves if the new folder is already there.
Before risky operations (a skipped-record load, an import that rewrites days, a
restore), the backend writes timestamped *.bak-* copies next to the stores,
keeping the newest 10 of each.
Data from older versions. Earlier versions kept tasks, notes, templates
and the goal in the webview's localStorage. On launch, an installed build
moves any it finds into appdata.json: list items are added by id, nothing
already in the file is replaced, a copy of what was moved is saved as
appdata.json.bak-<time>, and the localStorage copies are then removed.
Development builds (pnpm tauri dev) skip this: their webview storage is
separate from the installed app's, but the data folder is shared. The payout
settings used to be device settings: an installed build in local mode, and
the web app, move this device's values into the accountSettings record
once, unless it's set already, and then remove them from localStorage.
Backups. Settings → Data Management exports everything above to one JSON
file (format v3, with a copy of this device's own settings) and imports it
back. Import Data adds what isn't already present (existing data wins) and
leaves this device's settings alone. Restore Exactly asks first, then
replaces everything with the file's, this device's settings included, in one
change (the backend keeps its .bak copies, or its server snapshots, first);
a section an older file doesn't have (work logs in v1, withdrawals and payout
settings before v3) is left as it is. The app reloads after either. Settings →
Automatic Backups writes the same file to a folder daily, weekly or monthly,
keeping the newest N, and lists the backups in that folder with Restore
exactly and Merge for each. They start on (weekly, into
Documents/TaskMint Backups; macOS asks once for access to Documents) unless
you've saved a backup setting before.
Reset. Settings → Data Management → Reset All Data wipes all three data
files, their .bak copies and localStorage.
Server mode. Settings → Server connects this computer to a TaskMint
server (Run the stack). Enter the address you open it at in a
browser, a username and a password. It must be https://, except for a
server on this computer (http://localhost:8080). The app signs this
computer in with a device token, which stays in the OS keychain (never in a
file or the webview), and restarts on the server's data.
- The tray, idle detection, the long-run reminder, captures and folder backups keep working, and what they change is changed on the server. Changes made in a browser or on another computer show up live.
- The files on this computer are left as they were. Copy this computer's data here adds them to the server's: lists merge by id, nothing there is replaced, and timers still running in the files are stopped as of then. Disconnect signs this computer out and restarts on its files.
- Without the server, changes fail and the tray says so; the app reconnects by itself. A revoked token ("Signed out") needs signing in again; so does changing or resetting the account's password.
- Sessions are dated in the time zone of the computer that started them.
- To try it with a development build, set
TASKMINT_DATA_DIRto a scratch folder: the build keeps its files and its logs (inlogs/) there and uses its own keychain item, so the installed app's are left alone.
apps/extension/ is a Chrome extension that captures your DataAnnotation
work logs from the payments page and delivers them to the app over
http://127.0.0.1:43117, or through a taskmint:// link that launches the app
if it's closed. Its options can send them to a TaskMint server instead, with
a "Browser extension" token from the web app. The app accepts localhost deliveries only from this
extension's fixed ID. Payloads that arrive only by link wait in Payouts for
review, since any web page can open a link. When the app window isn't in
front, each capture's result also shows as a desktop notification. See
apps/extension/README.md.
If VITE_SENTRY_DSN is set at build time, crashes and errors are reported to
Sentry. Reports contain the error and where in the app it happened. Task
names, notes, earnings and work-log data are stripped before sending, and
there's no session replay or tracing. Turn it off in Settings → Privacy.
Prerequisites: Node 24 (.node-version), pnpm (pinned via
packageManager; corepack enable picks it up), and rustup. The Rust version
and its components are pinned in rust-toolchain.toml; rustup toolchain install in the repo installs them. node scripts/check-toolchains.mjs checks
that the Dockerfiles' rust: and node: images match the pins.
pnpm install
pnpm tauri dev # desktop app with hot reload (Vite on :1420)
pnpm dev # Vite only, for tauri dev; a plain browser has no backend and stops at the startup errorTaskMint in a browser runs against the server: see Run the stack and Web app.
Quit the installed TaskMint before running pnpm tauri dev. Dev and
release builds share the app identifier, so they share the data dir. The
single-instance lock and the capture server port also collide.
App icons. The masters are in assets/brand/: icon.png (the app icon),
icon-macos.png (the same on Apple's icon grid), the wordmark logo.png and
logo-dark.png, and the leaf mark.png. After changing the icon, regenerate
the bundle icons, then replace the .icns with the macOS-grid one:
cd apps/desktop && pnpm tauri icon ../../assets/brand/icon.pngcd apps/desktop && pnpm tauri icon ../../assets/brand/icon-macos.png -o /tmp/taskmint-icons && cp /tmp/taskmint-icons/icon.icns src-tauri/icons/tauri icon also writes Android, iOS and Windows Store sizes the bundle
doesn't use; delete them again:
cd apps/desktop/src-tauri/icons && rm -rf android ios Square*Logo.png StoreLogo.png 64x64.pngThe menu-bar icon (src-tauri/icons/tray-template.png, a black leaf on
transparent that macOS tints) and the images in packages/client/public/ and
apps/extension/icons/ are made from the same masters.
Copy .env.example to .env to set a Sentry DSN; otherwise reporting stays
off.
pnpm verify (scripts/verify.sh) runs every gate below in order; run it before merging.
It needs Docker: it ends with the server's gates in the compose test
profile, and the images built and smoke-tested as a stack of their own
(Run the stack), with the desktop's server mode run against
it (apps/desktop/src-tauri/tests/server_mode.rs).
pnpm lint # Biome: formatting and lint for the TypeScript and JavaScript
pnpm typecheck # tsc for sources and tests, every package
pnpm build # tsc for every package + desktop vite build
pnpm test # vitest in every package
pnpm test:coverage # vitest, held at 100% coverage
cargo test --workspace # Rust tests (bare `cargo` skips the desktop crate;
# server tests need DATABASE_URL, see "Server")
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo llvm-cov -p taskmint-core --fail-under-lines 100 --fail-under-functions 100 --fail-under-regions 100 # verify.sh adds the policy's ignore regex
scripts/check-server.sh # the server's .sqlx check and coverage; needs DATABASE_URL
node scripts/check-coverage-policy.mjs # see docs/coverage-policy.md
node scripts/check-toolchains.mjs # the Dockerfiles match the pinned toolchains
pnpm test:scripts # the release script's logic (node --test)
node scripts/smoke.mjs # the running stack, end to endpnpm lint:fix applies Biome's formatting and safe fixes (biome.jsonc says
which rules are off and why). pnpm install points git at .githooks/,
whose pre-commit hook checks the staged TypeScript with Biome and the Rust
with cargo fmt --check. It only checks; git commit --no-verify skips it.
.git-blame-ignore-revs lists the formatting-only commits: GitHub skips
them, and git blame --ignore-revs-file .git-blame-ignore-revs does locally.
CI (.github/workflows/ci.yml) runs them on pushes to main and on pull
requests: everything but the server on macOS, and the server's gates and the
smoke-tested stack in a Linux docker job. The Release workflow
(.github/workflows/release.yml) builds the release files (see Releasing).
Each branch with a change users would notice adds a note in changes/
(changes/README.md). The notes say how big the change
is and what CHANGELOG.md says about it. In the main checkout,
on main:
pnpm release --dry-run # the next version and its CHANGELOG section; changes nothing
pnpm release # bump, CHANGELOG, commit "Release X.Y.Z", tag vX.Y.Z
pnpm release publish # push main and the tag; CI builds and publishes the releasepnpm release takes the highest bump among the notes. It writes that
version into every manifest (the desktop, web, client and contracts
package.jsons, the three crates and Cargo.lock; the extension keeps its
own), adds the CHANGELOG section, deletes the notes, then commits and tags.
It builds nothing: the Release workflow builds every installer from the
pushed tag. If the commit fails, the version files, CHANGELOG and notes are
put back. Nothing is pushed.
| Option | |
|---|---|
--patch, --minor, --major |
bump this level instead (needed when there are no notes) |
--publish |
then push main and the tag |
--rebuild |
only build the current version's installers into releases/, with no bump or commit: this computer's (TaskMint-X.Y.Z-macos-arm64.dmg on Apple silicon), or the --target and --windows ones |
--target <name> |
with --rebuild, build this instead of this computer's installer (repeat it for more): macos-arm64, macos-x64 (cross-compiled on Apple silicon), windows-x64, chrome-extension |
--windows |
with --rebuild, also build windows-x64 (on a Mac, cross-compiled with cargo-xwin) |
pnpm release publish pushes main and the current version's tag. The tag
starts the Release workflow, which builds every file a release ships on
GitHub's runners and then creates the GitHub release with the version's
CHANGELOG section (gh run list --workflow release.yml follows it):
| File | Built on |
|---|---|
TaskMint-X.Y.Z-macos-arm64.dmg |
macOS, Apple silicon |
TaskMint-X.Y.Z-macos-x64.dmg |
macOS, cross-compiled for Intel |
TaskMint-X.Y.Z-windows-x64-setup.exe |
Windows |
TaskMint-X.Y.Z-chrome-extension.zip |
Linux (apps/extension/README.md says how to install it) |
Next to them are the desktop updates (see Desktop updates):
TaskMint-X.Y.Z-macos-{arm64,x64}.app.tar.gz and their .sig files,
TaskMint-X.Y.Z-windows-x64-setup.exe.sig, and latest.json.
The release is created only once all four are built, and a re-run replaces
the files of a release that already exists. The repository is public, so a
published release's files are public too. Running the workflow by hand
(Actions → Release → Run workflow), or a pull request that changes how
releases are built, builds the same files as workflow artifacts and
publishes nothing. The desktop builds report errors to Sentry when the
repository has a VITE_SENTRY_DSN secret (Settings → Secrets and variables
→ Actions); without it they report nothing.
The installers aren't signed (the Mac app is signed ad hoc). The first time a downloaded build is opened, macOS says it can't verify TaskMint: open System Settings → Privacy & Security and click Open Anyway. On Windows, SmartScreen says it protected the PC: click More info, then Run anyway.
A plain pnpm tauri build still works for a build that isn't a release; its
installer stays in target/release/bundle/.
The installed app updates itself from the latest GitHub release. A few
seconds after launch, and then once a day, it reads
https://github.com/BeardedUnicorn/taskmint/releases/latest/download/latest.json
(Settings → Updates checks now, or turns the automatic check off). When that
names a newer version, the app asks. On yes it downloads the update, checks
its signature, installs it and restarts. On macOS the update replaces the app
where it is. On Windows the installer runs in passive mode (a progress window
and no questions), then starts TaskMint again. The Rust side does all this
with tauri-plugin-updater (src/platform/os.rs, src/updates.rs), so the
webview needs no extra permission. A development build checks but won't
install.
Updates are signed with a minisign key:
- The public key is
plugins.updater.pubkeyinapps/desktop/src-tauri/tauri.conf.json. The private key and its password are the repository secretsTAURI_SIGNING_PRIVATE_KEYandTAURI_SIGNING_PRIVATE_KEY_PASSWORD. Only the Release workflow's build step sees them, and a tag's build stops at once if the key isn't set. - With the key in the environment, a build also makes the updates
(
src-tauri/tauri.updater.conf.jsonturns oncreateUpdaterArtifacts). The macOS update is made from theappbundle target, sobundle.targetsmust keepappnext todmg. The Tauri CLI refuses to sign with a public key it can't read, such as a placeholder.pnpm release --rebuildchecks each signature against the public key before the update goes intoreleases/, so a mismatched key can't ship. Without the key, as in a localpnpm release --rebuild, it builds the installer only. - The workflow's last step writes
latest.jsonfrom the signatures and the CHANGELOG section. requireSignedVersionis on. The bundler records the version in each signature, and the app installs an update only when that matches the versionlatest.jsonannounces. So an older signed build can't be passed off as a newer one.- Keep a copy of the private key and its password away from this computer (in a password manager, say). Without them, no later release can update the installed apps.
- A new key (lost or leaked): run
pnpm --filter @taskmint/desktop tauri signer generate -w ~/.tauri/taskmint-updater.key, replace both secrets andplugins.updater.pubkey, and release. Installed apps can't verify an update signed with the new key, so that one release has to be downloaded and installed by hand. Updates work again after that. - Releases before 2.3.0 have no updater, so 2.3.0 is installed by hand once.
Settings → Updates and its dialog come from apps/desktop/src/updates.ts,
UpdatePrompt.tsx and settings/UpdatesSection.tsx.
docs/qa/excluded-code-checklist.md
says how to try an update against a local feed.
The backend logs through tauri-plugin-log to stdout and to the app log dir
(~/Library/Logs/com.taskmint.desktop/ on macOS). Release builds log info and
above, dev builds include debug.
TaskMint in a browser is three services and a migration job, all in
compose.yaml: Postgres, the server, and the web app's nginx in front of
it. Docker with Compose v2 is all it needs.
cp .env.example .env # then set ADMIN_PASSWORD
docker compose up --build -d --wait # builds the images, waits until healthy
node scripts/smoke.mjs # optional: checks the stack end to end
open http://localhost:8080| Service | What it is | Published on |
|---|---|---|
db |
Postgres 18; its data is in the db-data volume |
127.0.0.1:${DB_PORT:-5433} (for the server's tests) |
migrate |
the server image, applying the migrations before the server starts, then exiting | – |
server |
the API (apps/server) |
127.0.0.1:${SERVER_PORT:-3000} (for the web app's dev server) |
web |
the web app (apps/web), served by nginx, which passes /api to server |
127.0.0.1:${WEB_PORT:-8080}: open this one |
server-tests |
profile test only: the server's gates in Linux (below) |
– |
The first admin. Set ADMIN_USERNAME and ADMIN_PASSWORD in .env
before the first start: a server that starts with no users creates that
admin. Sign in, then invite people from Settings → Invites. To recover
access, print a one-time link from the command line:
docker compose exec server taskmint-server admin create-invite --role admin
docker compose exec server taskmint-server admin reset-password <username>PUBLIC_ORIGIN must be the address you open, http://localhost:8080
by default. Invite and reset links start with it, and the server refuses
changes from any other origin, so http://127.0.0.1:8080 in the address bar
gets "cross-origin request refused". Change it along with WEB_PORT.
scripts/smoke.mjs (plain Node 22+, nothing to install) goes through the
stack the way people use it. Through nginx, it signs in as the admin from
ADMIN_USERNAME/ADMIN_PASSWORD (the environment, or .env). It invites a
new user, who signs up, makes tokens and opens an event stream. It saves a
task, starts and stops a timer, and posts a capture as the browser
extension would. Each change must reach the stream, and a stale save must be
refused. It then checks the security headers, that a capture token can't
run commands, and that the admin can't see the new user's data. Last, it
revokes the tokens and checks that the stream closes, then signs the user
out and disables them. Each run leaves one disabled smoke-… user behind,
so verify.sh runs it on a throwaway stack of its own (project
taskmint-verify, ports 18080, 13000 and 15433). Set TASKMINT_URL to
test another address.
The server's own gates (the .sqlx check, and its tests at 100% coverage)
run in Linux in the test profile, the same way CI runs them. The tests
make their own databases on db:
docker compose --profile test run --rm --build server-testsThe first run builds everything, which takes several minutes. The
server-tests-* volumes keep the build for the next run.
docker compose ps # health of each service
docker compose logs -f server # JSON lines; LOG_FORMAT=text in .env for plain text
docker compose up --build -d --wait # after pulling changes; migrations run first
docker compose down # stop (the data stays)
docker compose --profile test down -v # stop, and delete the database and the test buildA copy of the database, and putting one back (into an empty database:
down -v, then up once before restoring):
mkdir -p backups # git ignores it
docker compose exec -T db pg_dump -U taskmint --clean --if-exists taskmint > backups/taskmint.sql
docker compose exec -T db psql -U taskmint -v ON_ERROR_STOP=1 taskmint < backups/taskmint.sqldocs/runbook.md has scheduled backups and checking that one restores.
-
Containers: each has a read-only root filesystem (with tmpfs where nginx and Postgres write), no Linux capabilities, and no-new-privileges. Each runs as a non-root user: the server is distroless with no shell, nginx runs as uid 101, and Postgres as uid 70 from the start. Each has memory and process limits, and logs rotate at 3 × 10 MB.
-
Networks:
webshares a network withserveronly.dbis on another network, so the web app can't reach it. Everything is published on 127.0.0.1 only. -
Headers: nginx sends a content security policy, nosniff, a referrer policy, COOP, CORP,
X-Frame-Optionsand a permissions policy, and hides its version. The server setsCache-Control: no-storeon the API. -
Stopping: the server ends its event streams when it's told to stop, so
docker compose downtakes a moment, not the 10 s before a kill. Open tabs reconnect when it's back. -
Load: password hashing (argon2id, 19 MiB each) runs one per CPU at a time, so a burst of sign-ins waits instead of exhausting the server's memory. Sign-ins and links are rate-limited per address and per account.
-
Logs: nginx's access log writes invite and reset links with the secret replaced by
[redacted], and leaves out the Referer. The server's request spans name the route, never the path.
docs/runbook.md has the operator's procedures: backups and restores, upgrades, account recovery, and what to do in an incident. docs/security-review.md has what was reviewed, the accepted risks, and what a deployment others can reach needs.
Before the internet. This stack is for one machine. It has no TLS, and
COOKIE_SECURE=false sends the session cookie over plain HTTP. To expose
it, put an HTTPS proxy in front of web (adding HSTS there). Set
COOKIE_SECURE=true and PUBLIC_ORIGIN to the https:// address, use a
strong POSTGRES_PASSWORD, and stop publishing db and server. Each
server variable also takes <NAME>_FILE, for Docker secrets.
apps/server (taskmint-server) is the multi-user server that the web app
and the desktop app's server mode talk to: Rust, axum and PostgreSQL.
Accounts are invite-only: an admin hands out one-time invite and
password-reset links, and there's no email. It runs the same rules as the
desktop app (crates/taskmint-core) on each account's data in Postgres.
The web app (apps/web, below) is how people use it.
Run it with the rest of the stack in Docker: Run the stack.
Everything except the health checks is under /api/v1. Errors are JSON
{"error": "…", "code": "…"}.
| Endpoint | Who | What |
|---|---|---|
GET /healthz, GET /readyz |
anyone | the process is up; the database answers too |
GET meta |
anyone | {version, apiVersion, minDesktopVersion} |
POST auth/login |
anyone, rate-limited | sets the session cookie, returns {user, csrfToken} |
POST auth/logout, GET auth/me, POST auth/password |
session | a password change signs out the other sessions; me also accepts a full token |
POST auth/device-token |
username and password, rate-limited | a full token for the desktop app |
DELETE auth/device-token |
the token itself | revokes it (the desktop app disconnecting) |
GET invites/:token, POST invites/:token/accept |
anyone, rate-limited | accepting creates the account and signs it in |
GET password-resets/:token, POST password-resets/:token/accept |
anyone, rate-limited | sets the password, signs out everywhere, signs in |
GET/POST tokens, DELETE tokens/:id |
session | API tokens, scope capture (browser extension) or full; the secret is shown once |
GET admin/users, PATCH admin/users/:id, POST admin/users/:id/password-reset |
admin session | disable/enable and change roles (never the last active admin); reset links |
GET/POST admin/invites, DELETE admin/invites/:id |
admin session | invite links with a role and a note |
POST commands/:name |
session or full token |
a desktop command by name (get_sessions, start_task, …) with its arguments as the JSON body; replies and errors are the desktop's |
GET events |
session or full token |
the account's events as server-sent events |
POST capture, GET capture/ping |
capture or full token, X-TaskMint-Capture |
the browser extension's captures, and its "Test" button |
Sessions are an HttpOnly, SameSite=Strict cookie that slides to 30
days on use. Requests that change something and use the cookie must send the
session's CSRF token in X-CSRF-Token, and cross-origin ones are refused
(Sec-Fetch-Site/Origin). There's no CORS: browsers reach the API through
the web app's origin. Tokens go in Authorization: Bearer tmk_….
Passwords are hashed with argon2id; tokens, cookies and links are stored as
SHA-256 hashes. Disabling a user signs them out and revokes their tokens.
Admin actions, password changes and token changes go to audit_events.
Commands run on the caller's own data; no request names a user. A
command's sessions are dated in the zone the client sends in
X-TaskMint-TZ (an IANA name such as America/New_York), or else the
account's. Errors carry core's message, word for word: 400 rejected or
invalid_args, 404 unknown_command, and 409 conflict when a record is
saved with a stale baseRevision (another tab or device changed it). Each
record, session, ledger entry and mapping is a row of its own, and a row
the server can't read is reported and left as it is. Before a change that
replaces data (a restore, an import that rewrites days), a copy goes in
data_snapshots; the newest 10 of each kind are kept.
Events are named as the desktop app's (timer-instance-started,
app-records-changed, …) with their JSON payloads as data. A stream that
falls behind, and every stream after Reset All Data, gets resync: read
everything again. Signing out, a password change or reset, a revoked token
or an admin's change to the account closes the account's streams; clients
still signed in reconnect.
Environment variables; each also accepts <NAME>_FILE, a file holding the
value (for Docker secrets).
| Variable | Default | |
|---|---|---|
TASKMINT_BIND |
0.0.0.0:3000 |
listen address |
DATABASE_URL |
– | Postgres URL; without it the libpq PG* variables are used |
TASKMINT_PUBLIC_ORIGIN |
http://localhost:8080 |
where people open the web app: link base and the trusted origin |
TASKMINT_COOKIE_SECURE |
true |
Secure cookie named __Host-taskmint_session; false for plain HTTP |
TASKMINT_TRUST_PROXY |
false |
take the client IP from the last X-Forwarded-For hop |
TASKMINT_SESSION_TTL_DAYS |
30 |
idle time before a session expires |
TASKMINT_INVITE_TTL_HOURS, TASKMINT_RESET_TTL_HOURS |
72, 24 |
link lifetimes |
TASKMINT_MAX_BODY_MB |
32 |
request body limit |
TASKMINT_ADMIN_USERNAME, TASKMINT_ADMIN_PASSWORD |
– | the first admin, created when there are no users |
RUST_LOG, TASKMINT_LOG_FORMAT |
info, text |
log filter; json for one JSON object per line |
The binary's subcommands: serve, migrate, healthcheck (the image's
HEALTHCHECK; there's no shell in it), and admin create-invite|reset-password.
The tests run against a real Postgres: each test gets its own fresh
database on the compose db.
docker compose up -d --wait db
export DATABASE_URL=postgres://taskmint:taskmint@127.0.0.1:5433/taskmint
cargo test -p taskmint-server
scripts/check-server.sh # the .sqlx check, then coverage at 100%check-server.sh is what the compose test profile runs in Linux
(docker compose --profile test run --rm --build server-tests), so it also
works with no Rust tools on the host.
SQL queries are checked at compile time against apps/server/.sqlx, so
builds need no database (.cargo/config.toml sets SQLX_OFFLINE). After
changing a query or adding a migration, refresh it with sqlx-cli 0.8.6
(cargo install sqlx-cli --version 0.8.6 --no-default-features --features rustls,postgres --locked):
cd apps/server
cargo sqlx migrate run # bring the compose database up to date
cargo sqlx prepare # rewrite .sqlx; commit itMigrations are forward-only: never edit one that has shipped.
apps/web (@taskmint/web) is TaskMint in a browser, against the server:
the same app as the desktop's (packages/client), with its data on the
server instead of in local files.
- Pages of its own: signing in (
/login), and the one-time links an admin hands out:/invite/<token>creates an account,/reset/<token>sets a new password. Every other path is the app. - Settings gains Account (change password, sign out) and API tokens (for the browser extension and scripts; a secret is shown once), and for admins Users (roles, disable, reset links) and Invites.
- Live across tabs and devices: timers, sessions, records and imports follow the server's event stream. A change saved from an out-of-date view is refused rather than writing over a newer one: the app then shows the newer version and says so.
- What only the desktop app has: idle detection and the long-run reminder, automatic backups into a folder, the system tray, and the localhost capture server. Their settings are hidden. Exports are downloads, and imports use the browser's file picker. ⌘N and ⌘, belong to the browser, so the command palette (⌘K) offers both instead.
- Many tabs: each tab holds one event stream open, and a browser opens at most six connections to a site over HTTP/1.1, so a seventh tab of the app stalls until one closes. Serving it over HTTP/2 removes the limit.
Develop it against the compose server (/api is proxied to it):
docker compose up -d --wait db migrate server
pnpm --filter @taskmint/web dev # http://localhost:1421The image (apps/web/Dockerfile) builds the app with Node 24 and serves it
with unprivileged nginx (apps/web/nginx/default.conf.template): content
security policy, long caching for the hashed assets, and an unbuffered proxy
for the event stream.
A pnpm workspace and a Cargo workspace share the repo root. The plan for running TaskMint in browsers against a server is in docs/adr/0001-web-support.md.
packages/client/ React app (@taskmint/client)
src/components/pages/ One folder/file per page (payouts/, settings/, tasks/)
src/contexts/ App-wide providers: sessions, tasks, work logs, backups, …
src/hooks/ Data hooks (useSessionManagement, useWorkLogs, useDataBackup, …)
src/services/ Tasks, notes, templates (appRecords.ts: the appdata.json cache)
src/utils/ Pure logic: time math, payouts, work-log import, Sentry
src/types/ Shared types and zod schemas
public/ Favicon, wordmark and app icon
packages/contracts/ Wire types, CommandMap and constants (@taskmint/contracts),
generated from taskmint-core: don't edit src/generated
crates/taskmint-core/ Business rules shared by every host: sessions, timers, work-log
import, restore, idle, the wire contract (pure: no I/O, no clock)
apps/desktop/ Desktop app (@taskmint/desktop): Vite entry + Tauri shell
src/tauriBackend.ts The client's Backend: every call goes through dispatch
src-tauri/src/dispatch.rs The one IPC command: shell commands here, data commands to the backend
src-tauri/src/backend/ LocalBackend: one lock, run taskmint-core on a copy, save, then swap
src-tauri/src/store/ sessions.json / worklogs.json / appdata.json and their .bak copies
src-tauri/src/files.rs Save/open dialogs, the backup folder, showing a file in Finder
src-tauri/src/capture_server.rs localhost capture server and taskmint:// deep links
src-tauri/src/tray/ System tray
src-tauri/src/legacy_identifier.rs Moves the data left under the pre-2.1 identifier
src-tauri/src/config.rs Constants
src-tauri/capabilities/ Frontend permission grants
apps/web/ Web app (@taskmint/web): Vite entry, sign-in pages, HttpBackend
src/httpBackend.ts The client's Backend: /api/v1/commands, and the event stream
src/settings/ Account, API tokens, Users and Invites sections
nginx/ The image's nginx config
apps/extension/ Work-log capture extension (MV3, TypeScript; built into dist/)
apps/server/ Server (taskmint-server): axum + Postgres, see "Server" above
migrations/ SQL migrations, embedded in the binary
.sqlx/ Query metadata for offline builds (cargo sqlx prepare)
tests/api/ HTTP and CLI tests against Postgres
assets/brand/ Logo and app-icon masters (see Development)
docs/ ADRs, the coverage policy, the security review, the runbook
qa/ The manual checklist for the code coverage leaves out
After changing a wire type, a command, or a constant in
crates/taskmint-core/src/contract.rs, regenerate the TypeScript and commit
it with the Rust (scripts/verify.sh fails while they differ):
cargo test -p taskmint-core --features ts --test contracts