A minimal, keyboard-driven time tracker inspired by Notational Velocity.
Prerequisites: Postgres.app running on port 5432.
Create the database (one-time):
createdb ttCopy the environment template and start the server:
cp .env.example .env
uvicorn app:app --reloadNavigate to http://localhost:8000. You'll be prompted to sign up on first run.
The .env file is gitignored. DATABASE_URL and SECRET_KEY are loaded from it automatically on startup.
Create the test database (one-time):
createdb tt_testInstall dev dependencies and run the suite:
pip install -r requirements-dev.txt
pytestTests use transaction-per-test rollback for fast, isolated runs against a real Postgres instance. No mocking of the database layer.
CI runs automatically on every push and pull request via GitHub Actions.
The app is designed to run on Fly.io's free tier — it hibernates when idle and wakes on the first request.
1. Install the Fly CLI and log in
curl -L https://fly.io/install.sh | sh
fly auth login2. Create the app and provision a Postgres database
fly launch --name tt-<yourname> --region iad --no-deploy
fly postgres create --name tt-<yourname>-db
fly postgres attach tt-<yourname>-db
fly secrets set SECRET_KEY="$(openssl rand -hex 32)"3a. (Optional) Enable password reset emails
The app uses Resend for transactional email. Without these secrets the forgot-password flow silently skips sending — everything else works normally.
fly secrets set RESEND_API_KEY="re_..."
fly secrets set RESEND_FROM="noreply@yourdomain.com"
fly secrets set APP_URL="https://tt-<yourname>.fly.dev"RESEND_FROM must be an address on a domain you have verified in the Resend dashboard.
4. Deploy
fly deployThe app will be available at https://tt-<yourname>.fly.dev. Redeploy after code changes with fly deploy.
New visitors land on the tracker immediately — no sign-up required. Tasks are stored in localStorage under the key tt_guest_tasks and survive page reloads. A banner at the top of the page reminds guests that their data is local and offers a one-click path to sign up. A "sign in" button also appears in the header.
How it works
- On load, if there is no
tt_tokeninlocalStorage, the app loads guest data fromlocalStorageand renders it directly — no redirect to an auth screen. - The auth form is a modal overlay (
position: fixed,z-index: 200) that floats above the tracker rather than replacing it. PressingEscapedismisses it. - When a guest signs up with email/password and has existing local tasks, those tasks are POSTed to
/dataimmediately after signup (before clearing the guest key). The local data becomes the user's server-side data, so nothing is lost. - Logging out switches back to guest mode: the
tt_tokenis removed, local guest tasks reload, and the banner reappears.
Local testing
- Open the app in a private/incognito window (no existing token).
- You should see the tracker with the guest banner and "sign in" in the header — no login screen.
- Add a task, reload — the task should still be there.
- Click "Sign up" in the banner, create an account — the modal should close and your tasks should carry over.
- Log out — the tracker should stay visible with the guest banner, showing the same local tasks.
The app supports sign-in with Google as an alternative to email/password. Both methods coexist — existing password accounts are unaffected. Accounts are matched by email: if the Google account email already exists in the database, that account is used; otherwise a new one is created with password_hash = NULL.
How it works
- The frontend loads the Google Identity Services (GIS) SDK and fetches the client ID from
GET /auth/google/client-id. - GIS renders a "Sign in with Google" button. When the user picks a Google account, GIS returns a signed ID token in the browser.
- The frontend POSTs the token to
POST /auth/google. The server verifies it server-side withgoogle-authand returns the same JWT the rest of the app uses.
Setup
- Go to Google Cloud Console → APIs & Services → Credentials → Create credentials → OAuth 2.0 Client ID (Web application).
- Add your origin(s) as Authorised JavaScript origins (e.g.
http://localhost:8000,https://yourapp.fly.dev). No redirect URIs are needed. - Copy the client ID.
Environment variables
| Variable | Default | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
(empty) | OAuth 2.0 client ID from Google Cloud Console. If empty, the button is hidden and the endpoint returns 501. |
Local setup
fly secrets set GOOGLE_CLIENT_ID="<your-client-id>" # production
# or add to .env for local dev:
echo 'GOOGLE_CLIENT_ID=<your-client-id>' >> .envLeave GOOGLE_CLIENT_ID unset to disable Google sign-in entirely — no button appears and no JS errors occur.
The app has a built-in forgot-password flow using Resend as the email provider.
How it works
- User clicks "forgot password?" on the sign-in screen and submits their email.
- If the email matches an account, a signed one-time token is stored in
password_reset_tokens(expires in 60 minutes) and an email is sent with a link likehttps://yourdomain.com/?token=<token>. - Opening that link shows a "set new password" form. On submit the token is marked used and the password hash is updated.
- The response is always
{"ok": true}regardless of whether the email exists, to avoid leaking account information.
Environment variables
| Variable | Default | Description |
|---|---|---|
RESEND_API_KEY |
(empty) | API key from resend.com. If empty, emails are skipped silently. |
RESEND_FROM |
noreply@doingit.online |
Sender address — must be on a domain verified in Resend. |
APP_URL |
https://doingit.online |
Base URL prepended to the reset link in emails. Set to http://localhost:8000 for local testing. |
Local testing without email
Leave RESEND_API_KEY unset. After submitting the forgot-password form, grab the token directly from the database:
SELECT token FROM password_reset_tokens ORDER BY expires_at DESC LIMIT 1;Then open http://localhost:8000/?token=<token> manually to reach the reset form.
| Key | Action |
|---|---|
| Type | Search existing tasks or name a new one |
↵ |
Start/stop the matched task — or create and start a new one if no match |
↑ ↓ |
Navigate the task list |
Tab |
Expand/collapse today's session log for the selected task |
Esc |
Clear the search |
You can also click any task row to start/stop it, and hover to reveal the ✕ delete button.
Only one task runs at a time — starting a new one automatically stops the current one.
seed.py populates a user's tasks with two weeks of realistic sessions — useful for testing the history view without waiting for real usage to accumulate.
python3 seed.py --email you@example.comIt reads DATABASE_URL from .env by default. To target a different database:
python3 seed.py --email you@example.com --db postgresql://localhost/ttIt generates sessions across the last 10 weekdays for five built-in tasks (deep work, email & slack, code review, meetings, planning) and also adds historical sessions to any existing tasks already in the account (React Query, Interview Prep). Safe to re-run — it never removes existing tasks or sessions, only adds new ones.
Later items can be marked as done. Each later item shows a green ✓ button (to the left of the red ✕) that moves the item to the "Done" list.
How it works
- Clicking the ✓ on a later item removes it from the Later list and records it as done with the current timestamp.
- A "See all Done" link appears below the Later list, linking to
/done-list. - The Done page shows all completed items sorted newest-first with infinite scroll (50 items per page).
- Stats at the top of the page show: average done per week over the last 10 weeks, done this week, and done this month.
- Guest users' done items are stored in
localStorage(tt_guest_done). Signed-in users' done items are stored in thedone_itemstable.
API endpoints
| Method | Path | Description |
|---|---|---|
POST |
/done |
Mark an item as done ({id, text}) |
GET |
/done?offset=0&limit=50 |
Paginated list of done items |
GET |
/done/stats |
Stats: this_week, this_month, avg_per_week |
All task data is stored per-user in a Postgres database. Locally this is the tt database on your Postgres.app instance. In production it's the Fly.io Postgres cluster attached to the app.
index.html — the app UI
app.py — FastAPI server (auth, data API, static files)
server.py — simple local server (no auth, reads/writes data.json)
seed.py — populates data.json with two weeks of sample sessions
requirements.txt — Python dependencies
requirements-dev.txt — dev/test dependencies (pytest, httpx)
.env.example — environment variable template (copy to .env for local dev)
tests/ — test suite
Dockerfile — container build for Fly.io
fly.toml — Fly.io configuration
