Time Tracker
1. Getting Started
Before you start tracking time, set yourself up in this order:
- Go to Settings and enter your personal information — the details your clients need to know about you, such as your email address and phone number.
- Go to the Clients tab (the third tab) and enter your client's information. This appears on your invoices when you bill them — the client's name, a contact person, email, phone number, etc.
- Tap the client you just added to confirm the information is correct. Then, below that, add a project: enter the project name and hourly rate.
- Go to the Home tab and select the project. You're ready to start tracking time.
2. Overview
Time Tracker is a personal, offline-first time-tracking app built for freelance billing. It tracks time against Projects (optionally linked to Clients) and Categories, calculates earnings from an hourly rate, and generates PDF invoices and time reports per client, per month. All data lives locally in a SQLite database on-device; macOS builds additionally sync that data to iCloud.
The app was built from a design spec and task-by-task implementation plan (both preserved under
docs/superpowers/), then diverged from that original plan in a few notable ways during
a later "UI overhaul" pass — most importantly, the original CSV-email reporting flow was replaced
by an in-app PDF invoice generator, and a full Clients feature was added that wasn't in the original
design. Section 12 covers what's actually shipped versus what was originally planned.
Clients → Projects → Categories → Time Entries. One active timer at a time.
Monthly PDF "Time Report" or "Invoice", per client, previewed in-app.
iCloud/CloudKit — macOS only. Disabled on iOS due to an Apple bug (see §8).
Catppuccin Mocha dark palette, plus an iOS-style light theme, user-toggleable.
3. Architecture
State management is Riverpod, persistence is Drift (a typed
SQLite ORM), and navigation is GoRouter with a single adaptive shell that shows
a bottom tab bar on iOS and a sidebar (NavigationRail) on macOS. The folder layout
cleanly separates data, features, shared UI, services, and sync:
- lib/
- main.dart — app entry, startup logging, error zone, splash wrapper
- app/
- app.dart — root widget, theme mode, sync trigger, stale-timer dialog
- router.dart — GoRouter routes & shell branches
- data/
- database.dart, database.g.dart (generated)
- tables/ — clients, projects, categories, time_entries
- repositories/ — client, project, category, timer
- features/
- dashboard/, entries/, projects/, categories/, clients/, reports/, settings/, splash/
- services/notification_service.dart
- shared/
- providers.dart, theme.dart, widgets/ (app_scaffold.dart, timer_display.dart)
- sync/cloudkit_sync.dart
Navigation map
| Route | Screen | Tab |
|---|---|---|
/dashboard | Dashboard (live timer + stats) | Home |
/entries | Time Entries list | Entries |
/clients, /clients/new, /clients/:id, /clients/:id/edit | Clients list / form / detail | Clients |
/projects, /projects/new, /projects/:id/edit | Projects list / form (nested in the Clients tab branch — no independent tab) | — (via Clients) |
/reports | Reports / Invoice screen | Bills |
/settings, /settings/categories | Settings, Categories | Settings |
Heads up: Projects has no top-level tab of its own — the bottom bar shows Home, Entries, Clients, Bills, Settings. Projects are reached through the Clients tab (a client's detail screen, or its "linked projects" list) and Categories lives one level under Settings, not as its own tab.
4. Data Model
SQLite database via Drift, currently at schema version 5. Migration history:
| Version | Change |
|---|---|
| v2 | Add categories.sortOrder |
| v3 | Add categories.projectId (per-project category scoping) |
| v4 | Create clients table; add projects.clientId |
| v5 | Add clients.company |
Clients
Added later — not in the original design spec.
| Field | Type | Notes |
|---|---|---|
| id | TEXT (PK) | UUID |
| name | TEXT | required |
| company | TEXT? | added in v5 |
| email, phone | TEXT? | |
| street, city, state, zip | TEXT? | mailing address, all optional |
| notes | TEXT? | free text |
| createdAt | DateTime |
Projects
| Field | Type | Notes |
|---|---|---|
| id | TEXT (PK) | UUID |
| name | TEXT | |
| hourlyRate | REAL | used for earnings calc |
| colorHex | TEXT | one of 8 fixed swatches |
| isArchived | BOOL, default false | hides from active pickers |
| clientId | TEXT? | FK → Clients.id, added v4 — projects can be unlinked |
| createdAt | DateTime |
Fixed project color palette (Catppuccin Mocha accents):
#CBA6F7 #89B4FA #A6E3A1 #F38BA8 #89DCEB #F9E2AF #FAB387 #CDD6F4
Categories
| Field | Type | Notes |
|---|---|---|
| id | TEXT (PK) | UUID |
| name | TEXT | e.g. Coding, Email, Meetings |
| colorHex | TEXT | |
| sortOrder | INT, default 0 | manual drag-reorder |
| projectId | TEXT? | FK → Projects.id, added v3 — nullable, so categories can be global or project-scoped |
Time Entries
| Field | Type | Notes |
|---|---|---|
| id | TEXT (PK) | UUID |
| projectId | TEXT | FK → Projects.id, required |
| categoryId | TEXT? | FK → Categories.id |
| startTime | DateTime | |
| endTime | DateTime? | null = timer currently running |
| note | TEXT? | free text |
Derived (not stored):
durationSeconds = endTime - startTime (or now - startTime while running)
earnings = durationSeconds / 3600 * project.hourlyRate
Relationships: Client 1—* Project (nullable FK — projects may be unlinked) · Project 1—* Category (nullable — categories may also be global) · Project 1—* TimeEntry · Category 0/1—* TimeEntry.
The original design spec called for a local-only MonthlyReport table tracking a
sentAt timestamp per project/month. This was never implemented.
Invoice numbering instead lives in SharedPreferences as a simple incrementing counter
(see §7).
5. Features & Screens
Dashboard Home tab
- Large live timer display (
HH:MM:SS) — highlighted purple while running, dimmed when idle. - Project dropdown (auto-selects the first project if none chosen) and category dropdown, with an inline "Add new…" option.
- Start / Stop control. There is no visible Pause button, even though a
pauseTimer()method exists in the repository layer (see §6). - Three stat cards: Today, This Week, Earned (today's earnings on the selected project).
- Empty state: "Add a project to get started" when no projects exist yet.
Time Entries Entries tab
- Lists only completed entries (running entry is excluded), grouped by project, most-recently-active project first.
- Each row: date, start–end time range, category, duration, delete icon.
- Swipe-to-delete plus an explicit delete button.
- Empty state: "No entries yet — Start a timer from the Home tab."
The original spec called for tap-to-edit and filtering by project/category/date range on this screen. Neither is implemented — entries can only be viewed and deleted, not edited or filtered.
Projects (reached via Clients tab)
- List with color-swatch avatar, name,
$X/hrsubtitle, edit icon. - Form: name, hourly rate, optional client picker, color swatch (8 fixed colors).
- Empty state: "No projects yet — Tap + to add one."
ProjectRepository.archive() and .delete() exist in code but
no button in the UI calls them — there is currently no way to archive or delete a
project from the app itself.
Categories (Settings → Categories)
- Drag-to-reorder list, scoped to whichever project is currently selected on the Dashboard.
- Add/edit via dialog with name + color picker; delete via icon button.
- Each row shows a running total of time logged in that category (including live elapsed time if the active timer is on it).
- Empty state: "No categories yet — Tap + to add one."
Clients Clients tab Added post-spec
- List: avatar initial, name + company/email/phone/city-state summary, delete (with confirmation), tap → detail.
- Also surfaces an "Unlinked Projects" section for projects with no client assigned.
- Detail screen: contact info card, linked-projects list (edit / unlink), and an "Add" menu to create a new project for this client or link an existing unlinked one.
- Form fields: name*, company, email, phone, street/city/state/zip, notes.
Reports "Bills" tab
See §7 for full detail — this is the biggest divergence from the original design.
Settings
- Your info (shown on invoices): name, phone.
- Report email: stored, but not currently wired to any send/share action (see §7).
- Appearance: Dark mode switch, persisted.
- Notifications: monthly report reminder toggle (see §9).
- Data: link into Categories.
Splash screen
A custom-painted analog clock (hour/minute hands, tick marks, a small "crown"), app title, and a
"© Copyright by John T. Draper" footer. Fades out after a fixed 10-second delay before revealing
the app. Not part of the original design spec's screen list, but present in shipped code and wraps
the entire app in main.dart.
Theming
Two full themes, switchable via the Settings dark-mode toggle: the original Catppuccin
Mocha dark palette, and an iOS-style light theme (white background, light gray cards,
black text, a lighter primary purple). Theme mode is persisted in SharedPreferences.
6. Timer Logic
All timer state lives in TimerRepository, a thin wrapper around the timeEntries table:
watchActiveTimer() // stream: row where endTime IS NULL (or null)
startTimer(...) // throws StateError('Timer already running') if one is active
stopTimer() // sets endTime = now() on the open row
pauseTimer() => stopTimer(); // literally an alias — see below
watchEntriesForMonth(y, m)
watchEntriesInRange(start, end)
watchAllEntries()
updateEntry(entry) // upsert
deleteEntry(id)
There is no real "pause." pauseTimer() is a straight alias for
stopTimer() — both close the open entry by writing an endTime. Resuming
later starts a brand-new TimeEntry row rather than continuing the previous one. In
practice this is fine since the Dashboard UI doesn't expose a Pause button at all, but it's worth
knowing if you ever add one — right now it would just behave like Stop.
The "only one active timer" rule is enforced purely in application code (a query for an existing open row before inserting a new one), not by a database constraint — a theoretical race condition exists but is very unlikely given the app is single-writer/single-device for local data.
Stale-timer detection
On app launch, if the active timer's startTime is more than 24 hours
old, a dialog appears: "Timer still running — A timer has been running since [date]. Stop it
now?" with "Keep running" / "Stop" actions. This check runs once at launch only —
it does not re-check every time the app is foregrounded.
7. Reports & Invoicing
This is the area that changed most from the original plan. The design spec described a CSV
export delivered through the native share sheet (share_plus → Mail). That is
not what ships today.
What actually happens
- Completed time entries are grouped by project for the selected month; any project with no linked client is skipped entirely.
- For each remaining project, the app builds a per-category breakdown (hours × rate = amount) and shows one card per client: hours, earnings, category lines.
- Two buttons per card: "Time Report" and "Invoice". Both
generate a PDF (via the
pdfpackage):- Time Report — categories × hours × rate table, titled "TIME ACCOUNTING", no amount column.
- Invoice — adds an Amount column and a "TOTAL DUE" box, titled "INVOICE [YYYY-MM-###]" with an auto-incrementing invoice number.
- The PDF opens in an in-app preview (
printingpackage'sPdfPreviewwidget) — with printing and sharing both disabled on that screen. You can view it, but there's currently no button to export, save, print, or email it from there. - Your name/phone from Settings populate the invoice's "From" block; the client's contact info populates "Bill To".
Invoice numbering: a counter in SharedPreferences (invoice_counter) is
peeked (not incremented) when previewing, and only committed after a PDF successfully generates —
so a failed/aborted generation doesn't burn a number.
Dead code: csv_exporter.dart still exists and correctly produces
the spec's original CSV format — but nothing in the app's UI calls it; it's exercised only by its
own unit test. The share_plus package is a dependency but is used nowhere
in lib/. The "Report Email" field in Settings is stored but not connected to any
actual send mechanism.
8. iCloud Sync
iOS: disabled. Sync only runs when defaultTargetPlatform == TargetPlatform.macOS.
This is a deliberate fix for a real bug: the cloud_kit plugin relies on deprecated
native APIs that Apple's iOS 26 changes broke, causing native crashes on device. This is an
Apple/plugin-side issue, not a bug in this app's sync logic.
Future<void> _sync() async {
// CloudKit sync only runs on macOS — the cloud_kit package uses deprecated
// APIs that cause native crashes on iOS 26.
if (defaultTargetPlatform != TargetPlatform.macOS) return;
if (kDebugMode) return;
try {
await ref.read(cloudKitSyncProvider).syncAll();
} catch (_) {
// CloudKit unavailable — continue offline
}
}
Sync is triggered on app launch and every time the app returns to the foreground. It also skips in
debug builds (kDebugMode check) — matching the fact that the macOS
DebugProfile.entitlements file has no iCloud/CloudKit capability at all; only
Release.entitlements does. Errors are swallowed so the app always continues fully
offline with no visible error.
The cloud_kit package is treated as a flat key-value store
(save/get/getAll/delete), with records
namespaced by prefix: Client:<id>, Project:<id>,
Category:<id>, TimeEntry:<id>, each value a JSON-encoded field
map, all under container iCloud.com.johndraper.timetracker.
| Entity | Push behavior | Pull behavior |
|---|---|---|
| Clients | Always overwrite remote | Insert any remote record missing locally |
| Projects | Always overwrite remote (so edits/client changes propagate) | Insert missing records |
| Categories | Only pushed if the remote key doesn't exist yet | Insert missing records |
| Time Entries | Only completed entries (endTime != null); also only if remote key doesn't exist yet | Insert missing records |
For Categories and Time Entries, "only push if the remote key doesn't exist" means local edits to an already-synced record are never pushed again. This only matters on macOS (where sync is active) and is a latent bug worth fixing if cross-device edits to categories or entries need to propagate after the first sync.
9. Notifications
Built on flutter_local_notifications + timezone. The Settings screen's
"Monthly report reminder" switch is the only trigger in the app:
- Turning it on requests notification permission, then schedules a recurring notification for the 1st of every month at 9:00 AM local time ("Monthly Time Report — Your time report is ready to send.").
- Turning it off cancels that scheduled notification.
- Nothing else in the app re-checks or re-schedules this — if permission is later revoked or the OS drops the notification, there's no self-healing check on next launch.
The setting defaults to "on" in stored preferences, but nothing schedules the actual notification automatically at first launch — only flipping the switch does. If you're not sure the reminder is actually scheduled, toggle it off and back on once.
10. Platforms & Build
| Setting | Value |
|---|---|
| App version | 1.0.0+1 |
| Bundle ID | com.johndraper.timetracker |
| iOS deployment target | 16.0+ |
| macOS deployment target | 13.0+ |
| Dart SDK constraint | >=3.0.0 <4.0.0 |
| CloudKit container | iCloud.com.johndraper.timetracker (both platforms) |
| Android / Web | Not targeted — no platform folders present |
iOS entitlements grant full iCloud/CloudKit capability (unused at runtime, see §8). macOS has two
entitlement profiles: DebugProfile.entitlements (no iCloud capability — matches the
debug-mode sync skip) and Release.entitlements (full iCloud/CloudKit, used for the
release builds where sync actually runs).
main.dart currently includes verbose [STARTUP] print statements and a
runZonedGuarded global error handler that just prints — left over from diagnosing an
earlier startup crash. Harmless, but worth cleaning up before a wider release if you want quieter logs.
11. Testing
9 test files, 19 test cases plus one empty placeholder widget test:
| File | Covers | Tests |
|---|---|---|
data/database_test.dart | Raw insert/read for Projects and Time Entries | 2 |
data/repositories/project_repository_test.dart | watchAll, archive, delete | 3 |
data/repositories/timer_repository_test.dart | start/stop, double-start throws | 3 |
features/dashboard/timer_logic_test.dart | Elapsed-time formatting | 1 |
features/projects/projects_provider_test.dart | Add project via Riverpod container | 1 |
features/reports/csv_exporter_test.dart | CSV format & totals (tests dead code, see §7) | 3 |
services/notification_service_test.dart | Next-1st-of-month edge cases | 3 |
shared/theme_test.dart | Theme colors/build | 3 |
widget_test.dart | Empty placeholder | 0 |
No test coverage exists for: Clients (repository/provider/screens), the Categories provider, Reports/invoice generation, Settings provider, CloudKit sync, the app shell/router, or the stale-timer dialog. Coverage is concentrated on the earliest-built core (database, repositories, theme) and thins out for everything added later.
12. Known Issues & Divergences from Original Plan
| Item | Detail | |
|---|---|---|
| Pause = Stop | pauseTimer() is an alias for stopTimer(); no real pause/resume of a single entry. | |
| iCloud sync (iOS) | Disabled entirely — Apple iOS 26 broke the cloud_kit plugin's deprecated APIs. | |
| Category/entry re-sync | On macOS, edits to an already-synced category or time entry are never pushed again (only new records sync). | |
| CSV export & email | Both replaced by PDF invoices; csv_exporter.dart and the share_plus dependency are unused dead code; the Settings "report email" field does nothing. | |
| PDF export/share | Invoice/Time Report PDFs can be previewed in-app only — no print, save, or share button on that screen. | |
| No entry editing/filtering | Time Entries screen supports delete only — no tap-to-edit, no filter by project/category/date. | |
| No project archive/delete UI | Repository methods exist; no button calls them. | |
| Notification self-healing | Reminder is only (re)scheduled by manually toggling the Settings switch; nothing checks/repairs it on launch. | |
| Light mode | Added in the UI overhaul; original spec said dark-only for v1 — this is a positive addition, not a bug. | |
| Clients feature | Not in the original spec at all — fully implemented CRUD + linking, a net-new capability. |
13. Build History
17 commits, oldest → newest, grouped into build phases:
1. Planning
chore: add design spec and implementation plan
2. Scaffold & config
feat: scaffold Flutter project with dependencies and iCloud entitlements → fix: correct bundle ID case, deployment targets, and iOS entitlement wiring
3. Foundations
feat: add Catppuccin Mocha theme → feat: add Drift database schema with Projects, Categories, TimeEntries tables → feat: add project, category, and timer repositories with tests → feat: add Riverpod providers, GoRouter navigation, and adaptive shell
4. Core CRUD
feat: implement Projects CRUD feature → feat: implement Categories feature → feat: implement Dashboard with live timer and stats
5. Entries + Reports v1
feat: implement Time Entries list with grouped view and swipe-to-delete → feat: implement Reports screen with CSV export and share_plus delivery superseded, see §7
6. Settings & scheduling
feat: add Settings screen with email config and monthly notification scheduling → fix: use super parameter syntax in AppDatabase.forTesting
7. Sync
feat: add CloudKit sync with offline fallback and stale timer detection
8. macOS polish
feat: verify macOS layout and fix desktop-specific UX issues
9. UI overhaul latest
UI overhaul: light/dark mode, iPhone deployment, timer fixes — this single commit is where the biggest divergences from the original spec landed: light theme, the entire Clients feature (schema v4/v5), the PDF invoice generator replacing CSV/share_plus, Categories moved under Settings, the "Bills" tab relabel, and the iOS CloudKit restriction for the iOS 26 bug.
14. Appendix: Dependencies
| Package | Version | Purpose |
|---|---|---|
| flutter_riverpod | ^2.5.1 | State management |
| riverpod_annotation | ^2.3.5 | Riverpod codegen annotations |
| drift | ^2.18.0 | Typed SQLite ORM |
| sqlite3_flutter_libs | ^0.5.24 | Native SQLite binaries |
| path_provider / path | ^2.1.3 / ^1.9.0 | Filesystem paths for the DB file |
| go_router | ^14.1.4 | Navigation / routing |
| cloud_kit | ^1.1.0 | iCloud sync (macOS only in practice, see §8) |
| share_plus | ^10.0.0 | Present but unused in lib/ |
| flutter_local_notifications | ^17.2.1 | Monthly reminder notification |
| shared_preferences | ^2.3.1 | Theme mode, invoice counter, settings |
| timezone | ^0.9.4 | Notification scheduling timezone math |
| uuid | ^4.4.0 | Client-side ID generation |
| intl | ^0.19.0 | Date/number formatting |
| ^3.10.8 | Invoice / Time Report PDF generation | |
| printing | ^5.13.1 | In-app PDF preview |
Dev dependencies
drift_dev ^2.18.0 · build_runner ^2.4.11 · mocktail ^1.0.4 · flutter_lints ^4.0.0