Skip to content

Repository files navigation

Even logo

Even

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.

React 18 TypeScript Vite Cloudflare Tailwind Swift PWA Latest release


Dashboard with a running auto-tracked session and the week's balance  Day detail sheet with editable sessions  The same dashboard in dark mode


Contents


Why Even

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.


Feature Tour

⏱️ Tracking That Starts Itself

Dashboard

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.


📊 One Number: Above or Below Even

Dark mode dashboard

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.


✏️ Fix the Record Afterwards

Day detail sheet

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 and Settings

History view

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

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.


🔀 Every Way to Toggle

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.

👥 Multi-User, Invite-Only

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.


How It's Built

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]
Loading
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
Email Resend — optional, for password reset and signup notices
Binary hosting Cloudflare R2, served through the Worker at /download/macos

Self-Hosting Guide

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.

1. Prerequisites

  • 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

2. Clone and Install

git clone <your-fork-url>
cd even
npm install
cd worker && npm install && cd ..

3. Create the Database

cd worker
npx wrangler d1 create simple-time-tracker

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

Upgrading 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 as schema.sql. SQLite's ALTER TABLE ADD COLUMN has no IF NOT EXISTS, so a migration adding a column you already have fails with duplicate column name — that one is safe to skip.

4. Configure the Worker

Every value that could be sensitive is a secretwrangler.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 48

FRONTEND_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 → SettingsVariables and SecretsAdd, 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>

5. Deploy the Worker

npx wrangler deploy

Note 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. If FRONTEND_URL isn'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 with npx wrangler secret list, or the dashboard's Variables and Secrets panel, before deploying.

Deploying without the CLI

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:

  1. In the dashboard, open your Worker → SettingsBuild, and connect your GitHub or GitLab repository.
  2. Set the root directory to worker — that is where this project's Wrangler config lives.
  3. Leave the deploy command as wrangler deploy (or use wrangler versions upload if 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.

6. Build and Deploy the Frontend

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.

7. Create Your Account

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.

8. The macOS Menu Bar App

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.even is reverse-DNS for a domain this author owns. Substitute your own in PRODUCT_BUNDLE_IDENTIFIER — which appears twice in the macOS project, once per configuration — and in the Keychain service constant in AppState.swift, where you can also delete the legacyService constant and its migration.

⚠️ Gatekeeper

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

🔔 Notifications

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.log records the outcome either way.

9. The iOS App

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.

10. NFC Tags

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.


📲 Install It Like an App

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.


Security Model

  • Password storage — PBKDF2-SHA256, 100,000 iterations, per-user random 16-byte salt.
  • Tokens — HS256 JWTs valid for 30 days, carrying a token_version that is bumped on password reset so existing tokens die immediately. A missing or non-numeric exp is rejected rather than treated as "never expires".
  • JWT_SECRET is 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 returns 404.
  • Admin routes sit behind a single is_admin gate.
  • 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.dev for this project's own Cloudflare Pages previews — change or drop that last one in a fork (DEFAULT_CORS_ORIGIN and the preview regex in worker/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 GET and 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_enabled is a feature flag, not an authorization boundary — it gates browser auto-tracking, not API access.

Development

# 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 dev

Both .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 --noEmit

The macOS app:

xcodebuild -project macos-app/SimpleTimeTracker.xcodeproj \
  -scheme SimpleTimeTracker -configuration Release SYMROOT=build

Releasing

package.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-tags

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


Project Structure

├── 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

License

MIT — do what you like with it, keep the copyright notice.


Built for one person's workday, and shared in case it fits yours.

About

Know when you've done enough — self-hostable work-hour tracking that detects when you start working, with a native macOS menu bar app

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages