Know when you've done enough.
Work-hour tracking for people who work at a screen. Even notices the moment you actually sit down and start working, tracks the day without being asked, and tells you the one thing that matters: whether you're above or below the hours you owe. Self-hostable on Cloudflare's free tier, with a native macOS menu bar app and an installable PWA.
- Why Even
- Feature Tour
- How It's Built
- Self-Hosting Guide
- Install It Like an App
- Security Model
- Development
- Project Structure
- License
Most time trackers assume you'll remember to press start. Screen work doesn't happen that way — you sit down, open something, and three hours disappear.
Even inverts it. The macOS app watches for the transition from idle to working and opens a session on its own; when your Mac goes quiet, the server closes it. What's left is a single honest number: how far above or below your target you are — today, this week, and all time. Vacation days and public holidays are subtracted from what you owe, so the balance stays true.
It is deliberately small. No projects, no clients, no invoicing. Just hours.
The macOS app checks the system's HID idle time every 30 seconds. After 10
minutes of no input, your next keystroke or mouse move opens a session tagged
auto. While you keep working it sends a heartbeat; 2 minutes after the
heartbeats stop, a Cloudflare cron closes the session — so a closed lid, a
crash, or a yanked power cable can't leave the clock running overnight.
It knows the difference between you working and your Mac merely being awake:
- Fast user switching — if someone else is on the console, tracking stops. System-wide idle time reflects their typing, so it isn't trusted while your login session is in the background.
- A locked screen counts as away, because anyone can wake it.
- Calls keep you "active" — CoreAudio is checked for microphone use, so a long meeting where you barely touch the keyboard doesn't read as idle. The menu bar shows 📞 while a call is live.
- A manual stop is respected. Auto-tracking only re-arms after genuine idle time, so pressing Stop doesn't restart the timer seconds later.
- Free days stay free. On non-work days, public holidays, and vacation days the menu bar shows 🌴 and nothing auto-starts. Track today anyway in the menu lifts that until midnight, and a manual start always works.
Pause auto-tracking for an hour or four, or switch it off entirely, from the menu bar.
Set a target per day, or a different target for each weekday. The dashboard shows today, the current week against its target, and an all-time balance — labelled above even or below even.
Days you don't owe hours for are excluded automatically:
- Vacation days, marked individually or as a date range
- Public holidays (a Bavarian calendar is built in)
- Weekends, per your configured work days
A counter tracks how many vacation days you have left this year. Dashboard cards can be reordered by dragging — or with the keyboard.
Tap any day to open it. Every session is editable: start time, end time, break minutes, and a note. Sessions that cross midnight are handled correctly.
- Add a session you forgot entirely
- Merge two sessions separated by a short gap
- Close a gap between neighbouring sessions
- Mark the whole day as vacation
- Delete what shouldn't be there
The macOS app helps here too: start a session shortly after finishing one and it offers to merge them, so a coffee break doesn't fragment your day.
History is a day-by-day list grouped under Today, Yesterday, and then by date, each with its total and every session's trigger icon — so you can see at a glance what was tracked automatically and what you started by hand.
Settings covers daily and per-weekday targets, work days, the holiday calendar, vacation days and yearly allowance, theme (light, dark, or follow the system), and your NFC toggle URL.
| Surface | What it does |
|---|---|
| Web / PWA | Start and stop, edit history, everything else |
| macOS menu bar | Auto-tracking, manual toggle, live day total or session time |
| NFC tag | Write the toggle URL to any tag; tapping it starts or stops a session |
The macOS menu bar shows the tracking state at a glance:
| Icon | Meaning |
|---|---|
🤖 HH:MM |
Auto-tracked session running |
✋ HH:MM |
Manual session running |
📞 HH:MM |
You're in a call — counted as active |
🔀 HH:MM |
Two sessions could be merged; open the menu to decide |
⏹ HH:MM |
Not tracking |
The time is the day total by default; switch it to session time from the menu. Both tracking icons are configurable.
Notifications are off by default — the 🔀 cue is the primary surface. Switch on Merge notifications in the menu if you also want a banner.
The first account created becomes the admin. After that, registration is closed unless the admin issues a single-use, email-bound invite (or the email is on an explicit allowlist). Admins can list users, delete them, and toggle browser auto-tracking per user. Password reset runs over email via Resend when configured.
One Worker, one SQLite database, one static frontend, two native clients. There is no server-side framework, and the Worker has no runtime dependencies — it is plain TypeScript against the Workers runtime.
flowchart LR
subgraph Clients
PWA["📱 React PWA<br/>Vite · Tailwind"]
MAC["🖥️ macOS menu bar<br/>SwiftUI · IOKit · CoreAudio"]
NFC["🏷️ NFC tag"]
end
subgraph CF["☁️ Cloudflare"]
W["Worker<br/>JWT · PBKDF2 · rate limits"]
D1[("D1 · SQLite<br/>users · sessions · locations<br/>nfc_tags · invites<br/>password_resets · rate_limits")]
CRON["Cron · every minute<br/>closes stale auto sessions"]
R2[("R2<br/>macOS DMG")]
end
PWA -->|HTTPS + JWT| W
MAC -->|HTTPS + JWT| W
NFC -->|token URL| W
W --> D1
CRON --> D1
W --> R2
W -->|reset & signup mail| RS[Resend]
| Layer | Choice |
|---|---|
| Frontend | React 18 + TypeScript (strict), Vite, Tailwind CSS |
| macOS app | SwiftUI MenuBarExtra, IOKit idle detection, CoreAudio call detection |
| API | Cloudflare Workers, plain TypeScript, zero dependencies |
| Database | Cloudflare D1 (SQLite), every row scoped by user_id |
| Auth | JWT (HS256, 30 days) + PBKDF2-SHA256 password hashing |
| Background job | Workers cron, one set-based UPDATE per minute |
| Resend — optional, for password reset and signup notices | |
| Binary hosting | Cloudflare R2, served through the Worker at /download/macos |
Even is built to be self-hosted: one Worker, one D1 database, one static frontend. Everything below fits inside Cloudflare's free tier.
There is no public instance to sign up for — you run your own, and you own the data. Registration on a fresh deployment is closed: the first account becomes the admin, and everyone after that needs an invite.
- A Cloudflare account — Workers, D1, and optionally R2
- Node.js 20+ and npm
- Any static host for the frontend (Cloudflare Pages, Netlify, Vercel, …)
- Optional: a Resend account for password-reset email
- Optional: Xcode 15+ on macOS 14+ to build the menu bar app
git clone <your-fork-url>
cd even
npm install
cd worker && npm install && cd ..cd worker
npx wrangler d1 create simple-time-trackerCopy the database_id from the output into worker/wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "simple-time-tracker"
database_id = "YOUR_DATABASE_ID"Apply the schema. schema.sql is complete and idempotent — it is the single
source of truth for a fresh install:
npx wrangler d1 execute simple-time-tracker --remote --file=./schema.sqlUpgrading an existing deployment? Apply only the files in
worker/migrations/that your database hasn't seen yet, in numerical order. They exist so a database created before a feature landed converges on the same shape asschema.sql. SQLite'sALTER TABLE ADD COLUMNhas noIF NOT EXISTS, so a migration adding a column you already have fails withduplicate column name— that one is safe to skip.
Every value that could be sensitive is a secret — wrangler.toml has no
[vars] block at all. It does still commit your D1 database_id and R2
bucket_name, which are account-scoped identifiers rather than credentials: they
are useless without your Cloudflare API token, but replace them with your own:
npx wrangler secret put JWT_SECRET # required — at least 32 characters
npx wrangler secret put FRONTEND_URL # required — https://even.example.com
npx wrangler secret put ALLOWED_EMAILS # optional — comma-separated allowlist
npx wrangler secret put MAIL_FROM # optional — verified Resend sender
npx wrangler secret put RESEND_API_KEY # optional — enables email at all| Secret | Purpose |
|---|---|
JWT_SECRET |
Signs and verifies tokens. Required. |
FRONTEND_URL |
Your frontend's origin — the only cross-origin caller the API accepts, and the base for password-reset and invite links. Required. |
ALLOWED_EMAILS |
Comma-separated emails allowed to register without an invite. The first account to register becomes the admin; afterwards you can issue invites and leave this unset. |
MAIL_FROM |
Sender for password-reset and signup mail, e.g. Even <noreply@your-domain.com>. Must be a domain verified in Resend. Unset means no email is sent at all. |
RESEND_API_KEY |
Enables email. Unset means no email is sent at all. |
JWT_SECRET is validated on every request: if it is missing or shorter than 32
characters, the Worker returns 500 for everything rather than signing tokens
with a weak key. Generate one with:
openssl rand -base64 48FRONTEND_URL is equally load-bearing — with it unset, CORS falls back to an
intentionally invalid origin and every browser request is blocked. That is
deliberate: a misconfigured deployment should fail visibly rather than quietly
accept requests from, or link to, somebody else's domain.
Do not put these back in
[vars]. Defining a name in both places means a committed plain-text value can overwrite the secret on your next deploy — undoing the move and re-publishing the value.
Prefer clicking to typing? None of this needs the CLI. In the dashboard, go to
Workers & Pages → your Worker → Settings → Variables and Secrets →
Add, choose type Secret, and Deploy. To the Worker there is no
difference between a secret set here and one set with wrangler.
Optional. Without it, /download/macos answers 404 and everything else works —
only the in-app DMG link needs it:
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "simple-time-tracker-assets"For local development, put the same keys in worker/.dev.vars (gitignored) —
wrangler dev reads them exactly as it reads secrets in production:
JWT_SECRET=at-least-thirty-two-characters-long-dev-secret
FRONTEND_URL=http://localhost:3000
ALLOWED_EMAILS=you@example.com
MAIL_FROM=Even <noreply@localhost>npx wrangler deployNote the URL it prints — that's your API base. The cron trigger that closes
stale auto sessions is declared in wrangler.toml and registered on deploy; no
extra setup needed.
Order matters when migrating an existing deployment. Deploying removes any plain-text
[vars]from the live Worker, so set every secret in step 4 before you deploy. IfFRONTEND_URLisn't already a secret when the deploy lands, CORS falls back to an invalid origin and your frontend is locked out until you set it. Check withnpx wrangler secret list, or the dashboard's Variables and Secrets panel, before deploying.
You never have to run wrangler locally if you don't want to. Workers Builds
connects this repository to your Worker and builds on every push:
- In the dashboard, open your Worker → Settings → Build, and connect your GitHub or GitLab repository.
- Set the root directory to
worker— that is where this project's Wrangler config lives. - Leave the deploy command as
wrangler deploy(or usewrangler versions uploadif you'd rather review each version before it goes live).
The Worker's name in the dashboard must match name in worker/wrangler.toml,
or the build fails. Everything else — the D1 schema, secrets, cron triggers, R2
buckets — is also doable entirely in the dashboard; D1 has a SQL console under
your database, so you can paste schema.sql there instead of using the CLI.
From the repository root, point the app at your API:
cp .env.example .env# .env — your Worker's URL, no trailing slash
VITE_API_URL=https://your-worker.your-subdomain.workers.dev.env is gitignored, so your URL never lands in a commit. This value is
inlined into the client bundle at build time — it is public by design, and no
secret belongs in this file.
Then build:
npm run build # type-checks, then builds into dist/A missing VITE_API_URL fails the build rather than producing a bundle with no
API URL.
Deploy dist/ to any static host — build command npm run build, output
directory dist, and set VITE_API_URL in the host's environment. The resulting
origin must match FRONTEND_URL in wrangler.toml, or the browser will be
blocked by CORS.
Open the deployed frontend and register with an email listed in
ALLOWED_EMAILS. That first account becomes the admin. From Admin you can
then invite others; each invite is single-use and bound to one email address.
Build it yourself — recommended, since you get a signature tied to your own machine and no quarantine to clear:
swift scripts/generate-macos-icon.swift
xcodebuild -project macos-app/SimpleTimeTracker.xcodeproj \
-scheme SimpleTimeTracker -configuration Release \
SYMROOT=build
cp -R build/Release/Even.app /Applications/Or download Even.dmg from the
latest release.
Launch it, click the menu bar icon, choose Set up…, and enter your API URL,
email, and password. Your password goes into the macOS Keychain — never to
disk in plaintext. Only the API URL and email are stored in
~/Library/Application Support/simple-time-tracker/config.json, written 0600.
To start it at login: System Settings → General → Login Items → +.
Logs land in ~/Library/Logs/simple-time-tracker.log.
Forking? Change the identifiers.
com.thomaskleinert.evenis reverse-DNS for a domain this author owns. Substitute your own inPRODUCT_BUNDLE_IDENTIFIER— which appears twice in the macOS project, once per configuration — and in theKeychainserviceconstant inAppState.swift, where you can also delete thelegacyServiceconstant and its migration.
Releases are ad-hoc signed rather than notarized, so a downloaded build arrives quarantined. Clear it after copying to
/Applications:xattr -dr com.apple.quarantine /Applications/Even.app
Off by default. The one notification Even sends is the "merge these sessions?" banner, and that prompt already appears as 🔀 in the menu bar title with Merge / Keep separate in the menu.
Turn the banner on with Merge notifications in the menu bar. macOS asks for permission at that point, not at launch.
If the consent prompt is dismissed without an answer, macOS records the request as refused and never asks again — the menu then shows "Blocked — allow Even in System Settings ▸ Notifications", which is where it has to be re-enabled. The log at
~/Library/Logs/simple-time-tracker.logrecords the outcome either way.
On iOS, Even installs from Safari as a PWA — no build step, nothing from Xcode. Open the site, then Share → Add to Home Screen; see Install It Like an App.
NFC tags need no app installed at all — see below.
Settings → NFC shows a personal toggle URL containing a secret token. Write that URL to any NFC tag with a tag-writing app (NFC Tools, for example); tapping the tag toggles tracking without opening anything. The tab closes itself a couple of seconds later.
The token is the credential, so treat the URL like a password — anyone holding it can start and stop your timer. Settings can revoke it (any tag using it stops working immediately) and generate a fresh one.
Even is a PWA, so it installs straight from the browser with no store involved.
iOS / iPadOS — open the site in Safari → Share → Add to Home Screen. It launches fullscreen with its own splash screen and no browser chrome.
Android — open in Chrome → menu → Install app.
Desktop — Chrome and Edge show an install button in the address bar.
Browser-based auto-tracking (idle detection inside the tab) is enabled per user
by an admin and currently needs Chrome's IdleDetector. Everywhere else — and in
the installed PWA — tracking is manual; the macOS app is what makes it automatic.
- Password storage — PBKDF2-SHA256, 100,000 iterations, per-user random 16-byte salt.
- Tokens — HS256 JWTs valid for 30 days, carrying a
token_versionthat is bumped on password reset so existing tokens die immediately. A missing or non-numericexpis rejected rather than treated as "never expires". JWT_SECRETis enforced — at least 32 characters, or the Worker refuses to serve at all.- Every row is user-scoped. Reads and writes filter on
user_id; asking for another user's session, location, or NFC tag returns404. - Admin routes sit behind a single
is_admingate. - Rate limits, per minute: 10 logins per IP, 5 registrations per IP, 5 forgot-password and 5 reset-password per IP, 10 macOS downloads per IP, 10 NFC toggles per IP, 120 API calls per user.
- CORS is restricted to
FRONTEND_URL, plus loopback origins for development and*.simple-time-tracker.pages.devfor this project's own Cloudflare Pages previews — change or drop that last one in a fork (DEFAULT_CORS_ORIGINand the preview regex inworker/src/index.ts). - Invites are 128-bit random, email-bound, single-use, and expiring. Password-reset tokens are single-use with a one-hour lifetime.
- SQL is parameterised everywhere — no string interpolation into queries.
- macOS credentials live in the Keychain; the config file holds no secrets.
Worth knowing:
- The NFC webhook is a
GETand its token is in the URL, so anything that fetches that URL — a crawler, a link preview, a corporate scanner — toggles your timer. Treat it like a password and rotate it from Settings if it leaks. web_autotrack_enabledis a feature flag, not an authorization boundary — it gates browser auto-tracking, not API access.
# API on :8787, against a local SQLite database
cd worker
npx wrangler d1 execute simple-time-tracker --local --file=./schema.sql
npx wrangler dev# Frontend on :3000 (see vite.config.ts), pointed at the local API
echo "VITE_API_URL=http://127.0.0.1:8787" > .env.local
npm run devBoth .env and .env.local are gitignored, and .env.local takes precedence, so
your local setup never ends up in a commit.
Checks:
npm run build # tsc -b && vite build
cd worker && npx tsc --noEmitThe macOS app:
xcodebuild -project macos-app/SimpleTimeTracker.xcodeproj \
-scheme SimpleTimeTracker -configuration Release SYMROOT=buildpackage.json is the source of truth for the version. Bump it and tag in one step:
npm version patch # or minor / major / an explicit 0.1.35
git push --follow-tagsnpm version writes the new version to both manifests and their lockfiles, commits
them together, and creates the matching v* tag — scripts/sync-version.mjs runs as
its version hook to keep the worker package in step. Use it rather than git tag,
which would bypass the bump and let the tag and manifests drift apart.
The web app shows its version at the bottom of Settings; Vite inlines it from
package.json at build time.
Pushing the tag triggers .github/workflows/release-macos-app.yml, which builds a
universal binary, packages Even.dmg, attaches it to a GitHub release, and uploads it
to R2. The macOS app takes its own version from the tag via MARKETING_VERSION.
├── src/ React PWA
│ ├── components/ Layout, Timer, DaySheet, SessionCard
│ ├── pages/ Dashboard, History, Settings, Admin, auth pages
│ └── lib/ api client, store, date/format helpers,
│ useAutoTrack, Capacitor bridge
├── worker/
│ ├── src/index.ts the entire API and the cron job
│ ├── schema.sql complete schema for a fresh install
│ └── migrations/ incremental upgrades for existing databases
├── macos-app/ SwiftUI menu bar app
│ └── SimpleTimeTracker/ AppState (tracking engine), Menu, Login, Settings
├── scripts/ icon generation, splash screens, version sync
├── public/ icons, splash images, manifest, service worker
└── docs/ logo and screenshots for this README
MIT — do what you like with it, keep the copyright notice.




