Skip to content
BeardedUnicornPublic

About

A desktop time tracker and earnings ledger for tracking work sessions, hourly rates, and payouts. Built with Tauri, React, and TypeScript.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

443 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TaskMint

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.

What it does

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.

Where data lives

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_DIR to a scratch folder: the build keeps its files and its logs (in logs/) there and uses its own keychain item, so the installed app's are left alone.

Browser capture (optional)

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.

Error reporting

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.

Development

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 error

TaskMint 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.png
cd 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.png

The 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.

Checks

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 end

pnpm 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).

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 release

pnpm 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/.

Desktop updates

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.pubkey in apps/desktop/src-tauri/tauri.conf.json. The private key and its password are the repository secrets TAURI_SIGNING_PRIVATE_KEY and TAURI_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.json turns on createUpdaterArtifacts). The macOS update is made from the app bundle target, so bundle.targets must keep app next to dmg. The Tauri CLI refuses to sign with a public key it can't read, such as a placeholder. pnpm release --rebuild checks each signature against the public key before the update goes into releases/, so a mismatched key can't ship. Without the key, as in a local pnpm release --rebuild, it builds the installer only.
  • The workflow's last step writes latest.json from the signatures and the CHANGELOG section.
  • requireSignedVersion is on. The bundler records the version in each signature, and the app installs an update only when that matches the version latest.json announces. 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 and plugins.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.

Logs

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.

Run the stack

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.

Check it

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-tests

The first run builds everything, which takes several minutes. The server-tests-* volumes keep the build for the next run.

Day to day

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 build

A 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.sql

docs/runbook.md has scheduled backups and checking that one restores.

How it's locked down

  • 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: web shares a network with server only. db is 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-Options and a permissions policy, and hides its version. The server sets Cache-Control: no-store on the API.

  • Stopping: the server ends its event streams when it's told to stop, so docker compose down takes 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.

Server

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.

API

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.

Configuration

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.

Develop and test

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 it

Migrations are forward-only: never edit one that has shipped.

Web app

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:1421

The 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.

Project layout

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

About

A desktop time tracker and earnings ledger for tracking work sessions, hourly rates, and payouts. Built with Tauri, React, and TypeScript.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages