
cranthony/time-tracker-client
Developer toolsA client to interact with the Time Tracker MCP's summaries
Overview
A Flutter app for the Time Tracker MCP server. For now it shows your , by day, and has a button to record a new one; tap a note to change its description or time, or to delete it. An page, from the bar at the bottom, shows a day's events from your calendar; tap one to see or edit it, including the actions done at it and what happened: who it was with and for, where, and notes. Each event shows its summary, its actions' diamonds -- overlapping, in their colors -- by its length and priority chips at the top right, and below, as many of its actions' names as its time leaves room for. Where events overlap, the timeline tints that time red and labels it "overlap". Its puts a cursor at now, which a tap on the timeline moves: its arrows step it, and a "+" either side makes the event that way, a box between two cursors -- its top and its bottom, alike. Each cursor's time, after its corner (┌ or └), says what it's on -- "end of Lunch", "start of Work", "note: …
README
Time Tracker Client
A Flutter app for the Time Tracker MCP server. For now it shows your uncompacted notes, by day, and has a + button to record a new one; tap a note to change its description or time, or to delete it. An Events page, from the bar at the bottom, shows a day’s events from your calendar; tap one to see or edit it, including the actions done at it and what happened: who it was with and for, where, and notes. Each event shows its summary, its actions’ diamonds – overlapping, in their colors – by its length and priority chips at the top right, and below, as many of its actions’ names as its time leaves room for. Where events overlap, the timeline tints that time red and labels it “overlap”. Its + puts a cursor at now, which a tap on the timeline moves: its arrows step it, and a “+” either side makes the event that way, a box between two cursors – its top and its bottom, alike. Each cursor’s time, after its corner (┌ or └), says what it’s on – “end of Lunch”, “start of Work”, “note: …” – with scissors while it cuts into an event, and tapped, is set in a dialog: its day (the day shown, or the one before or after), hour and minute, each stepped by a “+” above and a “−” below, a minute past 59 carrying into the hour, an hour past 23 into the day. On its outside, a “+” and a “−” stretch and shrink the event there, and its handle drags it. The box itself, dragged, moves, and it trims the events in its way – splitting the one its cursor was placed in – unless they’re in a list: beside go-to-new, a checklist button, with a dot while any list has events, brings out Keep, Push up over Push down, and Cancel above ✕ and ✓, each with a dot while it has events. Tapping one picks events for it – a tap on an event, any on the timeline, picks it or unpicks it, ringed and marked with the list’s icon, the box’s buttons out of the way – then ✓ keeps what’s picked, and ✕ or back puts it back, the lists tucked away again either way. Events to keep stay as they are, the box going no further (its “+” greyed out); to push up or down, moved whole to just above or below the box when it – or what it pushes – reaches them, trimming what they run into; to cancel, cancelled, wherever they are. Once there’s a box, a tap on the timeline leaves it be. Then ✓ to fill it in. A cursor stops on the grid set in Settings and, as set there, at events’ edges and notes too: its buttons step it to the next such stop, and dragged near one it snaps there. While it’s up, the edges and notes it stops at are marked on the timeline, pulsing. Pressed and held, an event is moved the same way, pushing to start with. The mode the page is in – placing a cursor, creating an event, editing one, extending the compaction window – is named at the lower left, over its icon; back, like ✕, leaves it. With just the one cursor placed, ✓ cancels the events to cancel, if there are any, and is greyed out otherwise. While Claude has a compaction proposal open – what it says happened since the last compaction – it’s a band over the timeline, each change marked, to confirm: edits to the events in it edit the proposal, not the calendar, and a bar above confirms it as shown or leaves a note for Claude to revise it (see Compaction proposals). Above them, a summary shows how the day’s time is split by its top actions; swipe it to see the top-level groups, or the time by priority. A Plan page has four panes, swiped between or picked from its tabs, each but People with a search of its own. Landing on it loads every pane at once, and it shows what it had last time while it loads again. The Actions, People and Locations panes each have a summary, at the top, of the time in the 24 hours and 7 days before the last compaction – or after it, with “Next” – worked out from the events; its arrows move the window a day at a time, and tapping the date goes back to the last compaction.
- Actions (do), leftmost: what you do with your time, as a tree of groups – names that roll up the actions in them – with actions, the only thing events are given, as its leaves, each with its time in the window. The summary splits the time by the actions shown, or by priority. Tap a group to open or close it; tap an action to set its priority and color, or go on to its details. Only active actions take up one of the calendar’s event labels. An action Claude made is proposed until you approve it. The search finds actions by name, path and note, in their groups.
- Traits (how to be): each trait you define – none is built in. A trait is made of parts: chiefly judgments, which Claude makes of each event, each with a rubric, a rating scale of your own, and the facts it’s judged from (actions, action history and location history, each with a lookback, location, general notes, person notes); and counts, time spent, continuity and follow-through, which can count one action or group. Every part reads the events with someone, or those done for them. Tap one for its page: its parts, then its health (the last day’s score, the mean of everyone scored by it, and its trend); its pencil edits it, on a page of its own. Time spent’s target is written, or stepped, in hours and minutes.
- People (who): two sections, each folded away by tapping its head. Self has your health and the two habits you focus on (starred on a habit’s page), with a way to your page. People has the three you keep in front (starred on a person’s page, or from their menu), each with their time with you in the summary’s window and the last event with them – or the next, looking on; both picks are kept on the device. “All people and circles” opens everyone – there’s no search on the pane itself, with so few on it; its “+” is in People’s head – to search, sort (by health, when last or next seen, or time in 24 hours or 7 days, either way) and edit, on a page of its own: each with a context that tells people of the same name apart, in any number of circles. Tap a circle to see only its people; tap a person for their page: who they are, which traits apply to them (every active one, or those picked, with their own parts for any) and what matters to them; and, once scored, their events in brief – how many and their time, the most recent and the oldest – and those cancelled against their follow-through likewise, each with a button, “All events” or “All cancelled events”, to a page of them all. The summary splits the time by who it was with, or by circle (people in none are “Individuals”). Self’s page has their habits too (see Habits).
- Locations (where): the places events happen, each with a hint the assistant recognizes it by. The summary splits the time by where it was.
The app works out the traits’ scores itself, from the calendar’s events and Claude’s judgments of them, as the server’s scoring does: each person’s score of each trait for each of the last 7 days, each trait’s health (everyone’s mean), and each person’s relationship health (gray, disconnected, to green, healthy) and history; and each of Self’s habits’ scores and health (on track to off track). Each habit and person also has a consistency: how many days in a row, back from yesterday, they’ve been on track (or healthy) – rated 70 or more – shown under their rating as “1d”, “7d”, up to “>30d”. For it, the app rates further back than the week shown, only while it holds, and so keeps the 31 days before it too. Follow-through counts the events the user cancelled that count against each person or habit, which the server lists with them; their pages list them too. Every way the app cancels an event asks whether it counts: cancelling it, and making room for another – each event a move, a new event or clearing time would cancel is listed, a change of plan unless turned on. Deleting a series from an event on is a change of plan, as the server keeps it. As it opens, the app asks for the week either side of today, and for any day further back (or ahead) the traits read that it hasn’t kept from an earlier run; the Events page shares those days, and its saves change the scores at once. The scores are worked out off the UI thread – on another isolate; on the web, which has none, in a task of their own – and only when what they’re from changes: the events kept (a day fetched again, alike, changes nothing), or what the traits, people, actions and habits say, not just their being loaded again. Meanwhile the Traits and People panes show them as they were, with the thin bar along the top, and every page as they come in.
Later it will show the summaries the server generates.
It is built for Android and also runs on Windows and in Chrome. Chrome needs no extra tooling, so it’s the quickest way to try changes.
One-time setup (both platforms)
-
Install Flutter. Follow for your OS. When
flutter doctorruns clean for the platform you want, you’re done. -
Clone this repo and fetch packages:
git clone https://github.com/cranthony/time-tracker-client.git cd time-tracker-client flutter pub get -
Point it at your server. Copy
config.example.jsontoconfig.json(it’s gitignored) and setMCP_URLto your deployed server’s URL plus/mcp, the same URL you gave Claude.ai. You’ll find it in the Render dashboard.The web app can also take a relative URL, such as
/mcp, when it’s served from the server’s own origin (behind one corporate proxy, say). It’s resolved against the page’s address as a link would be:/mcpfrom the origin’s root,mcpfrom the app’s own path (lib/services/server_url.dart). The Android and Windows apps have no page to resolve it against, so they refuse a relative one at startup, saying so. In Git Bash on Windows, setMSYS_NO_PATHCONV=1first, or it rewrites/mcpinto a Windows path before Flutter sees it.If you skip
--dart-define-from-file=config.jsonin the commands below, the app runs in an offline demo mode with in-memory notes. That’s handy for UI work. To fill it with sample notes, events and a plan, see Try it with sample data.
Try it with sample data
To try the app on your own machine without a server or signing in, launch
it with SAMPLE_DATA=true. It starts with a realistic day of
sample notes, events and a plan, dated relative to today.
You need only Flutter and a clone of this repo (steps 1 and 2 above); skip
config.json.
In Chrome, which needs no other tooling:
flutter run -d chrome --dart-define=SAMPLE_DATA=true
On Windows (with the prerequisites installed), or on a plugged-in Android phone or a running emulator:
flutter run -d windows --dart-define=SAMPLE_DATA=true
flutter run --dart-define=SAMPLE_DATA=true # Android
Or build the web app once and serve it, then open :
flutter build web --dart-define=SAMPLE_DATA=true
python -m http.server 8765 --directory build/web
A few things to know:
- Don’t add
--dart-define-from-file=config.json. Sample data is used only when there’s noMCP_URL. With one, the app talks to your server and ignoresSAMPLE_DATA. - Changes aren’t saved. New notes, edited events and actions last until
the app restarts. Reloading the page in Chrome, or a hot restart (
Rin the terminal), starts again from the sample data. A hot reload (r) keeps them. - Without
SAMPLE_DATA=truethe app starts empty.
Run on Windows
Prerequisites (once):
- Visual Studio 2022 (the full IDE, not VS Code), with the “Desktop development with C++” workload. The free Community edition is fine.
- Developer Mode on (Settings → System → For developers). Flutter needs it to build plugins.
flutter doctorshould show a ✓ for “Visual Studio - develop Windows apps”.
Then:
flutter run -d windows --dart-define-from-file=config.json
The app opens in a phone-sized window. Click Sign in: your browser
opens the WorkOS sign-in page. After you sign in, the browser shows
“Signed in”, and the app loads your notes. Press r in the terminal to
hot-reload after code changes.
To build a standalone .exe instead:
flutter build windows --dart-define-from-file=config.json
# → build\windows\x64\runner\Release\time_tracker_client.exe
Run in Chrome
No prerequisites beyond Flutter and Chrome.
flutter run -d chrome --web-port 8765 --dart-define-from-file=config.json
Or build it once and serve the static files:
flutter build web --dart-define-from-file=config.json
python -m http.server 8765 --directory build/web
Then open . Click Sign in: a popup window shows the WorkOS sign-in page, then closes itself, and the app loads your notes. If Chrome blocks the popup, allow popups for the site and click again.
Serve it from localhost (any port). The server allows cross-origin calls
from localhost; for anywhere else, add that origin to the server’s
MCP_CORS_ALLOWED_ORIGINS.
In the browser, registering the app and fetching tokens go through the
MCP server’s /oauth/register and /oauth/token, which forward them to
AuthKit: AuthKit’s own endpoints don’t allow browser calls (no CORS
headers). Nothing needs configuring in WorkOS for this.
Hosted on GitHub Pages
Every push to main builds the web app and publishes it to
(.github/workflows/pages.yml). It needs:
- Pages set to deploy from GitHub Actions (Settings → Pages → Source).
- The repository variable
MCP_URL(Settings → Secrets and variables → Actions → Variables), the same value as inconfig.json. - The server’s
MCP_CORS_ALLOWED_ORIGINSset tohttps://cranthony.github.io(on Render: the service’s Environment tab).
Content-Security-Policy
The published app runs only its own code. The build serves CanvasKit (the
WebAssembly graphics engine) from the app’s own site rather than Google’s
CDN (--no-web-resources-cdn), and tool/web_csp.dart then adds a
Content-Security-Policy to index.html that:
- runs scripts and WebAssembly only from the app’s own site: no inline
scripts, no
eval, nothing from other sites; - lets the page connect only to its own site, the MCP server, AuthKit
(which the tool reads from the server’s metadata during the build) and
Google’s font server, which supplies fonts for emoji and other scripts,
and Open-Meteo’s forecast servers, for the weather.
A server built without sign-in has no such metadata, so no AuthKit; a
relative
MCP_URLis the page’s own site, so neither is added.
web/callback.html has its own fixed policy, and its script is in
web/callback.js.
The app also refuses to run inside a frame, where another site could hide
it under its own page so that your clicks land on the app
(“clickjacking”). Pages can’t send the header that forbids framing, so
web/start.js checks instead, and only loads the app in a window or tab
of its own.
To try the published setup locally:
flutter build web --csp --no-web-resources-cdn --dart-define-from-file=config.json
dart run tool/web_csp.dart build/web https://your-service.onrender.com/mcp
python -m http.server 8765 --directory build/web
Linux release packages
Every push to main that changes the version also packages the web app
for a self-hosted server
(.github/workflows/linux-release.yml):
time-tracker-web--for-AUTH_DISABLED-server.tar.gz, with its
SHA-256. It’s for a server built with AUTH_DISABLED (no sign-in),
which serves it itself: unpack it and point the server’s STATIC_SITE_DIR
at the unpacked folder. It’s built with MCP_URL=/mcp, the page’s own
server, and its Content-Security-Policy allows no sign-in service, so it
can’t sign in to a server that wants it. The server’s repository
publishes the matching server package.
It’s published to a Google Drive folder, which holds only the latest
package: each run copies the new one in and removes the older web
packages, leaving the server’s alone. rclone moves what it removes to
Drive’s trash, so an older package can be got back for 30 days. A push
whose version’s package is already there is skipped, and without the
DRIVE_RCLONE_CONF secret, so is everything, with a warning. The packages
aren’t published as GitHub Releases; the Android app’s are.
A pull request that changes the workflow, tool/ci/publish_to_drive.sh or
tool/web_csp.dart builds and packages it too, but publishes nothing.
To set up Google Drive (once; the server’s repository uses the same secret):
-
Install rclone (
winget install Rclone.Rclone) and make a remote calledgdrive, signing in to Google in the browser it opens:rclone config create gdrive drive scope=drive.filedrive.filelets it see only the files rclone itself makes, never the rest of your Drive. -
Store its config (it holds the sign-in token) as the
DRIVE_RCLONE_CONFsecret in both repositories;rclone config filesays where it is:gh secret set DRIVE_RCLONE_CONF -R cranthony/time-tracker-client "gh secret set DRIVE_RCLONE_CONF -R cranthony/time-tracking-google-calendar-mcp "PowerShell has no
<redirection, so there pipe the file in instead:$conf = rclone config file | Select-Object -Last 1 Get-Content -Raw $conf | gh secret set DRIVE_RCLONE_CONF -R cranthony/time-tracker-client Get-Content -Raw $conf | gh secret set DRIVE_RCLONE_CONF -R cranthony/time-tracking-google-calendar-mcpPipe it rather than passing it with
--body:--bodywould put the token on the command line, where other processes can see it. -
The packages go to
TimeTrackerat the top of your Drive. For another folder, set the repository variableDRIVE_FOLDER(an rclone path, e.g.gdrive:Releases/TimeTracker) in both repositories.
Run on Android
Install from Google Play (internal testing)
Every push to main builds a signed app bundle
(.github/workflows/android.yml) and
uploads it to Google Play’s internal testing track, as
com.cranthony.timetracker. Internal testing needs no review and no
waiting period, and only testers you list can install it. Open the
track’s opt-in link on the phone (Play Console → Testing → Internal
testing → Testers), and the app appears in the Play Store. If your work
profile’s Play Store only offers approved apps, install it from the
personal profile.
One-time setup:
- Create the app in the Play Console: Time Tracker, app, free.
- Upload the first bundle by hand. Google’s API can’t create an
app’s first release. Download
time-tracker.aabfrom the latest GitHub Release and upload it under Testing → Internal testing. Keep Play App Signing on: Play signs what gets installed, and our key (below) becomes the upload key that proves builds come from you. Add yourself as a tester. - Let GitHub publish. In Google Cloud, create a project, enable the
Google Play Android Developer API, and create a service account
with a JSON key. In the Play Console (Users and permissions), invite
the service account’s email with release permissions for this app.
Then store the key as a repository secret and delete the file:
Until this secret exists, the workflow skips the Play upload.gh secret set PLAY_SERVICE_ACCOUNT_JSON < key.json
The Play Console may also ask for app-content details (privacy policy, data safety, content rating) before it will release to testers; its setup checklist lists what’s missing.
Install the published build (no tools needed)
Every push to main also builds a signed APK and publishes it, with the
app bundle, as a GitHub Release. On the phone, open
in the browser, then open the downloaded file. The first time, Android asks you to allow installs from your browser. To update, do the same again: it installs over the old version and keeps you signed in.
Every build is signed with the same key, which is what lets updates
install over each other (and what Play checks as the upload key). It’s kept in the ANDROID_KEYSTORE_BASE64 and
ANDROID_KEYSTORE_PASSWORD repository secrets (a PKCS#12 keystore, key
alias upload), with a backup outside the repo. Losing it means
uninstalling the app to install a build signed with a new key. APKs you
build yourself are signed with your debug key instead, so Android won’t
install one over a published build, or the other way round, without
uninstalling first.
Build it yourself
Prerequisites (once):
- Android Studio, which installs the Android SDK. Open it once and let
it finish setup, then run
flutter doctor --android-licensesand accept. - On the phone, enable Developer options (Settings → About phone → tap Build number 7 times). Then turn on USB debugging under Settings → System → Developer options.
To run it while developing: plug the phone in over USB and accept the “Allow USB debugging?” prompt on the phone. Then:
flutter devices # your phone should be listed
flutter run --dart-define-from-file=config.json
To install it and keep it, without a cable afterwards:
flutter build apk --release --dart-define-from-file=config.json
flutter install # with the phone plugged in
Alternatively, copy build/app/outputs/flutter-apk/app-release.apk to the
phone and open it. You’ll have to allow installing from that source.
Tap Sign in, sign in in the browser, and Android returns you to the app.
Add-note button on the home screen
There are two ways to get a one-tap + on the home screen:
- Shortcut (labelled like your other icons): long-press the Time Tracker icon, then drag Add note from its menu onto the home screen. The launcher draws the icon and its “Add note” label, so they match the rest of your home screen, including its icon shape and themed icons. You can also tap Add note in that menu without pinning it.
- Widget (no label): long-press an empty spot on the home screen, choose Widgets, find Time Tracker, and drag Add note to where you want it. It’s a round + button.
Either one opens the app straight into the New note sheet, timed at the moment you tapped, even if it takes a few seconds to type the description. After you tap Add, it’s saved like any other note, including when you’re offline.
Calendar on the home screen
The Calendar widget shows your events from now to the next few days. Its title (“Calendar”, with its +) is at the top, or, set so, the bottom: long-press the widget and tap its settings (Android 12 and later; before that, you choose as you add it), then pick Title at the top or Title at the bottom. Each calendar on the home screen keeps its own.
Signing in (WorkOS AuthKit)
The MCP server is an OAuth resource server, and WorkOS AuthKit issues its tokens. The app signs in the same way Claude.ai does, so nothing needs configuring in WorkOS beyond what the server’s README already covers. Specifically:
-
It reads
/.well-known/oauth-protected-resource/mcpfrom the server to find AuthKit. -
On first sign-in, it registers itself through Dynamic Client Registration. The “Allow MCP clients to authenticate using DCR…” setting in WorkOS has to be on, which it already is if Claude.ai works.
-
It runs the authorization-code flow with PKCE in your browser. It asks for a token for the server’s URL (the
resourceparameter), which is the audience the server checks. -
It stores the tokens in secure storage (Android Keystore or Windows DPAPI) and refreshes them automatically. Use ⋮ → Sign out to forget them.
Browsers have no such storage, so on the web the tokens are only ever in the page’s memory, never in browser storage or on disk. Reloading the page or opening it again means signing in again, usually just a popup that closes itself, since AuthKit remembers you. While the page is open, its Content-Security-Policy keeps other sites’ scripts off it, but browser extensions allowed to read the site, and programs running as you, could still reach the tokens. The client registration (not a secret) stays in
localStorage, so the app reuses it instead of registering another client each time.
Redirect URIs it registers:
| Platform | Redirect URI |
|---|---|
| Windows | http://localhost:47291/callback |
| Android | com.cranthony.timetracker://oauth/callback |
| Web | callback.html next to the app, e.g. http://localhost:8765/callback.html |
Troubleshooting sign-in
- “Client registration failed”: DCR is off in WorkOS (Connect → Configuration), or WorkOS refused the redirect URI above. If it’s the Android one, try Windows first; that uses a plain localhost redirect.
- The browser says it can’t reach localhost:47291 (Windows): the app wasn’t waiting for the redirect. Click Sign in again. Also check nothing else is using port 47291.
- Chrome: “Failed to fetch” /
ClientExceptionloading notes: the server refused the cross-origin call. Serve the app fromlocalhost, or add its origin to the server’sMCP_CORS_ALLOWED_ORIGINS. - Signed in, but loading notes fails with HTTP 401: the token’s
audience doesn’t match.
MCP_URLmust be exactly the server’s public URL plus/mcp, with no trailing slash.
Without sign-in, behind a corporate proxy
On a network where a proxy in front of the server already signs people
in, the server can be built to skip sign-in (AUTH_DISABLED; see the
server’s README). The app needs nothing different: the same build works
with either server. It connects before it signs in, sending a token only
once it has one, and asks you to sign in only when the server answers
that it wants one (a 401). A server built with AUTH_DISABLED never does,
so the app never signs in to it.
Such a server says so in its reply to the MCP handshake (a flag,
time-tracking/authDisabled, in its _meta), which the app reads each
time it connects. While it’s set, the app makes it hard to miss:
- A strip on every screen. Amber, across the top, above every page and dialog, and never dismissed: “Sign-in off: the server trusts every request”.
- About and Settings say so, and why.
- No Sign out: there’s nothing to sign out of.
Notes that haven’t been saved yet
(For changes to events, see Changes waiting to save.)
A new note is kept on the device first, and then sent to the server. If you’re offline, the server is down, or you’re signed out, the note stays in the list, tinted and in italics, with its status underneath:
- Waiting to save / Saving…: on its way.
- Not saved · reason · retrying: the last attempt failed. The app tries again after 5s, 10s, 20s and 40s, then every minute, until it works. Tap the note to retry right away, or pull down to refresh.
- Not saved · sign in to save: sign in and it’s sent.
The × cancels a note that hasn’t been saved (with Undo). A note that’s being sent can’t be cancelled, since the server may already have it.
Unsaved notes survive closing the app. They’re kept in SharedPreferences on Android, a file in AppData on Windows, and localStorage in the browser. Once a send has failed, the next attempt first checks whether the server got the note after all, so a lost response doesn’t create a duplicate.
In the background:
- Android: when the app leaves the screen with notes still unsaved, it hands them to Android’s WorkManager. That runs once there’s a network connection, even if the app has been closed, and keeps retrying with backoff. Android decides exactly when, usually within minutes, and later when battery saver is on. It can’t sign you in, so if the sign-in has expired the notes wait until you open the app.
- Windows: a minimised app keeps running, so it keeps retrying.
- Chrome: it retries while the tab is open. A closed tab keeps the notes and sends them the next time you open the app.
Settings
A Settings page, from the menu, holds what’s kept on this device alone. For now that’s the grid the Events page’s cursors snap to, picked from a menu: every 5, 10, 15 (the default), 30 or 60 minutes from midnight, or none, to stop at any minute. Every cursor also stops at events’ edges and notes, each of which can be turned off there. And what a new or moved event’s Keep list starts with: rules, each removable, of historical events (all that ended by the last compaction – the default), the last of them, or the previous or next event, from the cursor, with an action or one in a group (never the event it starts in, nor the one moved). On Android, it’s also where Google Calendar events are turned on, and their calendars picked. So is the weather, and the units it’s shown in.
Google Calendar events
On Android, the Events page can show the events in your Google calendars beside your own, for reference. It’s off until you turn on Show its events in Settings’ Google Calendar section, which asks for Android’s calendar permission; refused, only this is off, and the section says so – refused for good, it offers the app’s Android settings, to allow it there. Under the switch, every Google calendar synced to the phone, by account, with its color: each account’s own is shown to start with. They’re read on the phone, from what the Google Calendar app syncs: nothing about them is sent to the server.
- On the timeline, a calendar’s event is drawn faintly at its times, in its color, behind your own events, side by side with any it overlaps; a strip at the timeline’s right edge marks each, so one under an event of yours still shows. Beside “now”, a button brings them over your own events instead, nearly solid (its icon’s dotted box over the solid one), and back.
- All-day events are pills in a strip over the timeline, in view wherever it’s scrolled to – one lasting days says until when – or, folded, a line naming them.
- Tapped, an event shows its time, location, calendar and description – what was written on Google Calendar’s website is HTML, drawn with its bold, italics, lists and links; a long one is cut off with More. Open in Calendar opens it in the calendar app; Log this puts the new-event box over its times, and the event it makes starts with its title, description (as plain text, links’ addresses after them) and location.
- Pulling the timeline down, or coming back to the app, reads them again; events you declined aren’t shown.
An event’s location has a second page, Map location, swiped to on
its location sheet: where it was as a maps app would find it, an address
or a place’s name, typed in full – a logged calendar event’s location
lands there. With one, a map button at the right of the event’s
location opens it in your default maps app (with a geo: link; Google
Maps in the browser on the web and Windows). If it’s just a web link –
to a map, or a video call – the button is an arrow instead, and opens
the link. A calendar event’s sheet has the same button by its location.
Weather
The Events page can show the weather where the device is, or for a place you give. It’s off until you turn on Show the weather in Settings’ Weather section, then it’s for one of:
- A place I give – to start: its latitude and longitude, in degrees, typed in. The device is never asked where it is, so turning the weather on asks nothing.
- Where this device is: only as this is picked does the app ask to know where the device is (roughly: the coarse location on Android, the browser’s or Windows’ location service elsewhere). Refused, the section says so, with Ask again – or, refused for good or with the location off, a button to the device’s settings.
The place, rounded to about a kilometer, goes to Open-Meteo for its forecast – free for non-commercial use, needing no key – and nothing about it goes to the server. Temperatures are in °F or °C, and rain in inches or millimeters, as Settings says (°F and inches to start).
- A strip over the timeline, under the day’s summary, gives the day’s sky, high and low, and its chance of rain (today’s from now); today, whether rain’s coming in the next three hours, by the quarter hour; and the week’s days, each with its sky and chance of rain, to tap to go to. Nothing shows for a day that’s past.
- The band beside the timeline, where the priorities are drawn otherwise, is the weather from now on: the sky’s color (sunny yellow, clouds grey, a clear night indigo), with a blue bar across it as wide as rain’s likely, labeled with the chance where it changes. It’s only as precise as a forecast can be: by the quarter hour for the next three hours, by the hour for a day and a half, then by night, morning, afternoon and evening through the day after tomorrow, then by the whole day for the rest of the week.
- The chance of rain in a part of a day, or a day, is the share of NOAA’s GFS ensemble’s 31 runs with a hundredth of an inch or more in it – not the likeliest hour’s, which would understate it.
- A forecast is fetched as the page opens, on coming back to the app and going to another day, once the last is half an hour old; each replaces the last whole, and is kept on the device, so the app shows it at once as it opens. One that can’t be had leaves the last, its time shown.
- In the background (Android), it’s fetched every 15 minutes too (Android’s shortest), so it’s up to date when you open the app – how often, or not at all, is set on the Background updates page, which says what the last did. For where the device is, that’s where it was last found while the app was open: the background fetch never asks where the device is, so needs no background location permission.
- The home screen calendar (Android) gives each day’s weather in its header: the chance of rain (whenever there’s any), the high and low – today’s, what’s left of it – and the sky’s icon. It’s redrawn with each forecast, in the app or in the background. On a narrow widget the days are named shorter (“Thu”), then the high and low go.
The forecast’s source is behind an interface (ForecastSource, in
lib/services/weather.dart), as is where the device is
(LocationSource): another service, such as the National Weather
Service’s, would be one more of them.
Background updates
On Android, the app fetches what it keeps while it’s closed, so it’s fresh when you open it: the notes and when they were last compacted, every action, trait, person, location and habit, and the week either side of today’s events. It fetches 10 minutes after each time Claude’s routines run – compacting notes, answering the notes on a proposal – and otherwise every 6 hours. Android’s WorkManager runs each fetch around its time: usually within minutes, later while the phone’s idle. Signed out, it stops till you sign in. What it keeps, the app reads afresh – each answer kept apart, so neither the app nor the fetch ever puts back an older one over what the other kept – and back in the app, the Events page shows the proposal it fetched at once, while it asks again.
The routines’ times are compaction schedule hints, kept on the server
(the Compaction Schedule tab of the calendar’s metadata spreadsheet): the
routines set them with set_compaction_schedule_hints, and the app reads
them with get_compaction_schedule_hints – it can’t change them. They’re
in the calendar’s time zone, which the app takes as the phone’s. The
Background updates page, from the menu, lists them, each with when the
app updates after it; says it otherwise updates every 6 hours; and shows
when the next update is, and what the last did. A server without the
hints leaves just the 6-hourly updates. On the web and Windows the app
fetches only while it’s open.
Changes waiting to save
Changes to events – editing, cancelling, adding or moving one – and what’s done to the open compaction proposal – editing it, leaving or withdrawing a note for Claude, confirming, finishing or abandoning it – don’t wait for the server: they’re kept on the device, shown at once as made, marked Waiting to save, and sent in the background, strictly in the order they were made. They survive closing the app, and Android’s background task sends them too, as it does notes.
While any are waiting, a line at the foot of every page says so: saving, paused, trying again later, or stopped. Tap it to see them, oldest first, each with how it’s going. From there:
- Pause stops sending (kept across restarts) until Resume.
- Try again now skips the wait after a failed attempt.
- Edit changes a waiting change to an event, a new event, or a cancel, in its place; a move that changed others is dropped and made again instead.
- Drop throws one away: what it changed goes back to how the server has it. Dropping a change to the proposal offers to drop those of it made after, on top of it, too.
A change that couldn’t be sent – offline, say – is tried again by itself after 5s, 10s, 20s and 40s, then every minute. One the server refuses (an overlap, say) stops the queue there, saying why, since what comes after may depend on it: fix it, try again, or drop it. One refused as changing history can be approved from there (Change history). Why it was refused names the events it’s about rather than their ids, each with Show, to go to it on the timeline, ringed.
What a waiting change changes can’t change again until it’s saved: an event it cancels or adds not at all, and the fields it sets (its title, its time, its actions) not until then. Their dialogs say so, those fields don’t open, and a change that would touch them – a move pushing it, say – says which change it’s waiting for. A proposal with edits waiting can’t be confirmed till they’re saved, and once confirming, finishing or abandoning it waits, nothing more of it can be changed. Series are still saved at once.
An edit of the proposal is sent from the revision you were looking at when you made it, so if Claude changed the same events since, the server says so, and the Events page does too, over the proposal, with those events marked: “Your change replaced Claude’s newer change to …”. What a confirm came to is said there too: recorded; planned again as a new revision, the calendar or notes having changed, to review and confirm; what’s left planned again after a write that couldn’t be made; or gone to Claude. Each stays until you tap OK – even if it was sent with the app closed.
A new event, a note for Claude, or abandoning the proposal is checked for before it’s sent again after an attempt nobody heard back from, so a lost response doesn’t make it twice; a confirm finishes applying instead, if the server has it. Nor is a cancel refused for having been made already, an event a move splits off made twice, or an event an edit of the proposal creates.
The app and the background task each send from what’s kept on the device, and may both be at it at once – the app coming back while the task runs, say. Each change to what’s kept is made whole, holding a lock both share (the task runs in the app’s process, in an isolate of its own), so neither’s change to it overwrites the other’s; and each change waiting is claimed before it’s sent, the same way, so only one sends it – and neither takes one while the other’s sending one, so they take turns. A change being sent says who’s sending it: “Sending, by the background task…”. It’s sent again only if whoever claimed it doesn’t say how it went within two minutes – killed mid-request, say – and then checked for first, as above. Nor can one being sent elsewhere be dropped.
Diagnostics
Diagnostics, in the ⋮ menu, shows how the server is doing, from its
get_health (see the server’s README). Its Server pane has three
graphs over one time range, the latest on the right:
- Tool calls – each call’s own work, filled, with the rest of its request (auth and transport) stacked on top, to the whole request’s time; a failed call is a red dot.
- Memory – the server’s resident memory after each call, whichever tool’s.
- Restarts – a tick at each time the server started: there’s one server, so each is a crash or a deploy.
Above them, pick which calls (all tools, reads, writes, an area – events,
notes, proposals, people, actions, habits – or one tool), how far back
(the last day, week, or everything), and whether to show each call or the
mean, median, 95th percentile or max of each 15 minutes, hour, 6 hours or
day. Each graph says the mean, median, 95th percentile and max of what’s
in view. A pinch – on a touchscreen or a trackpad, or Ctrl and the mouse
wheel – zooms all of a pane’s graphs in together, in time, and the buttons
zoom keeping the latest in view; two fingers drag along, and a double tap
goes back to the whole range. Scrolling scrolls the page. Pull down to
fetch again; the last fetched shows offline. A server without get_health
says so.
Swipe to the App pane for the app’s own health, recorded on the device – by the app and by its Android background task, both – with the same menus:
- Tool calls – each call the app made to the server, its whole time (retries, signing in and the network included): a line for the app’s, and one for the background task’s, failed calls dotted red; the last 100 of each tool are kept.
- Waiting to save – how many changes waited in each outbox (notes, actions, events), stacked, each step a change; the last 200 changes are kept.
- Slow frames – each frame that took more than 16 ms to build and draw, as tall as it took: those while Plan was open apart from the rest, and red over 100 ms, a freeze anyone would see; the last 300 are kept. None are recorded while Diagnostics is open: its graphs’ drawing isn’t what’s measured, and would only be drawn again.
- Plan visits – each visit to Plan, from opening it to everything loaded: a bar of the device’s own work in it that held up the screen – the time on actions and on people, rebuilding the page, and the trait scores where they’re worked out on the UI thread (the web) – each kind’s time its own, under the time it waited, on the server mostly; the last 50 are kept.
- Work on the device – each kind of that work’s mean, median, 95th percentile and longest run, whole, and how many runs; and why the trait scores were worked out again each time they were: the first time, or what they’re from changed – the events, traits, people, actions, habits, or the day. The last 100 of each kind are kept.
- Errors – how many of the last 20 are of the tools picked, and the newest; See all opens them on a page of their own, newest first: a call that failed, with its tool, or one the app didn’t catch, with where. Tap one to read it in full, and copy it.
Work is timed with a stopwatch around it (work_timing.dart), and slow frames as Flutter reports them. Recording is a few microseconds’ work in memory; it’s written to the device a few seconds later, and as the app goes to the background or the background task ends, under the same lock the outboxes share (see Changes waiting to save).
The graphs are drawn with fl_chart.
Compaction proposals
A scheduled Claude routine compacts notes into the calendar as a
proposal: “this is what happened from the last compaction to
through” (the server’s design).
Claude can’t apply one; only confirming it in the app does.
- The band. On the Events page, the proposal’s window is tinted and outlined, headed “What happened – to confirm” and ending at a “through” line. Each event in it says what the proposal does to it – moved (and from where), new, cancelled, renamed, new actions or facts – and whether Claude or you decided it; one as planned is plain. Those changed since the revision you last saw (kept on the device) have a dot. Its notes are lines across it, each with a badge: ↳ added to an event, ⇕ setting an event’s start or end, ⊘ left out. Under the bar is one of them – its time, text and what became of it – highlighted on the timeline, with arrows to step to the note before or after – back to the latest note the last compaction used, with a ✓, as context. Its pencil says what the note is for – added to the event it falls within (or whose start or end it sets), to another of its day’s, or left out – puts it back as Claude had it, or asks Claude about it.
- Editing it. Moving, resizing, cancelling or adding an event in the
band, or setting its actions, people or location, edits the proposal
(
amend_proposal), with the same dialogs and press-and-hold move as ever, and what it does to the events in its way. Its whole description, location and priority can be changed too; a cancelled one can be put back as planned. If the server refuses an edit (an overlap, say), it stays in the dialog to fix, and the proposal loads again. Below the band is the plan, changed on the calendar; before it, history, changed only after asking. So is an edit of the proposal that changes what an earlier compaction recorded – moving the start of, ending earlier, or cancelling an event that started before the band, as sleeping in pushes the morning: it asks “Change history?” first, and approved, the server keeps to it in every revision after. One it couldn’t tell, refused, offers Change history from the changes waiting. - Extending it. Its “through” tab, with a pencil, extends what happened past where Claude’s revision ran to: the end’s cursor, its anchor fixed at Claude’s end, dragged, stepped to its next stop (“+”, “−”), or typed in (its settings, which also say what it stops at) – never back past where it ends, nor past now – then Extend. The notes it takes in are added to the events they fall within, as yours: their badge is a person with a “+”, and they say so. The stretch you added is tinted apart, headed “extended by you”. Claude’s next revision runs to now, past it.
- Its details, under the bar, in pages swiped between and folded away like the day’s summary: its notes; its follow-through – how many cancels count against it, each with who it counts against, and a switch to say it’s a change of plan, or not – and what it adds, each person, action and location to settle: made now, one already here, or dropped, or asked about. Each is stepped through one at a time, and shown on the timeline.
- The bar. “Confirm … happened as shown” applies it. “Note for Claude” leaves a note, about an event or all of it; while one’s open it’s waiting for Claude, who answers it on its next run (or ask in a conversation), and it can’t be confirmed. “Notes” shows them, Claude’s replies beside them, and withdraws an open one. If the calendar changed since it was planned, it says what changed and asks to confirm again; if it can’t be planned any more, it’s handed to Claude; if applying it stopped partway, Retry finishes it. Its menu goes to it, or abandons it. Each waits to save, as changes do (see Changes waiting to save), and what came of it is said over the proposal. Applied, or abandoned, it’s gone at once – here or by the background task, as the notice says – and stays gone, though a load asked for before then answers with it open.
The sample data has one open, so SAMPLE_DATA=true shows it.
Habits
A habit is something you want to do well – practicing guitar mindfully,
cooking from scratch – about one action, or every action under one
group: its events are yours with that action, worked out from the tree
as it is when they’re judged or scored, so moving an action between
groups moves it in or out. Like a person, it’s held to traits: every
active one unless it picks some, each with its own parts if it likes (a
rubric for this habit alone). You’re at every one of its events, so a
part that reads events done for someone doesn’t apply; one that counts
an action names one in it, and the editor warns of one that doesn’t.
Claude judges its events beside everyone’s, under habit:, and the
events cancelled that count against its follow-through are listed with
it, as a person’s are; to judge past events, ask Claude.
The app scores them as it scores people, from those events and
judgments: a habit’s page shows its health, each trait’s score and
trend, how each was reached, and its events. An event’s notes name its
judgments for a habit by the habit. Self’s page lists them, with “Habit”
to add one; each has a page of its own, and a pencil that opens another
to edit it. They come from the server’s get_habits,
create_habit and update_habit; a server without them shows none, and
nothing else changes. The sample data has three: one on the Guitar
group with parts of its own; Eat well, with three meals a day never more
than 6 hours apart; and, built on it, one on Cook lunch or dinner with
every trait – with Claude’s judgments of a few of their events. Sam is
on the habit graph too, built on cooking.
The habit graph
The Habits pane draws them as a graph. Each habit can be built on others – its foundations – and on people; the people you put on the graph (the person button) can be built on habits and people too, a relationship on exercising and working well, say. Layer 0, at the bottom, has what’s built on nothing; each layer above, what’s built on the layers below it, a layer above the highest of its foundations. Each layer has a trend of its own: each day, its lowest rating.
Each is a chip: a habit in its action’s color, a person plain, with a person by their name. It has the name, the trend, the consistency, and a star if it’s starred. Tapping one opens its page; holding it (or a right click) picks what it’s built on, archives a habit, or takes a person off the graph. A habit’s editor picks its foundations too.
By default only what’s unlocked is shown: what’s built on nothing, and what has every foundation shown and on track – or not rated yet. A note says how many more wait, and “Show all” (the lock) shows them. The search looks within what’s shown, and shows each match, the matching text marked, with everything it’s built on. The pane opens scrolled to the highest layer that’s on track with every layer below it.
Foundations are habits, as habit:, and people, by id, in the
server’s foundations on a habit and on a person; a person with
foundations, even none, is on the graph. The server refuses a cycle.
A trait part of its own suits a habit like eating well: spacing, a clock that each event of its action winds back to the longest gap (6 hours, say). Exceptions – sleep – stop it, and can set the time left after one: an hour to breakfast after waking. Each day scores the share of its waking time the clock hadn’t run out.
Development
flutter analyze
flutter test
Tests cover the MCP client, the whole OAuth flow (against a fake AuthKit), the outbox of unsaved notes, and the notes screen, so none of them need a server.
Sample data
lib/demo/sample_data.dart is a realistic day of notes, events and a plan
for trying the app without a server. Its traits are made of facets and
cadences; its people, Self and six others in five circles, run from
healthy to disconnected, with Claude’s judgments of their recent events
behind each score; Self has three habits, one built on another, and Sam is on the habit graph; its actions are a tree of groups three levels deep,
with every status, their own and inherited colors, and health ratings,
including skipped periods, periods not yet assessed, and a proposed
rating. It’s dated relative to today, so it never goes stale. To run the app with it,
see Try it with sample data.
lib/demo/google_calendar_sample.dart gives it a phone’s Google calendars
too, on every platform: two accounts, with a family calendar, birthdays
and holidays, weekday meetings, and today, timed and all-day events with
descriptions in plain text and HTML.
Visual tests
test/visual renders the main screens with the sample data, at a phone’s
size, in light and dark: Plan (each pane, with sign-in off, People looking on, everyone
as listed and sorted, a person being edited, a Time spent part’s target, a circle’s people, searches
of people and actions, a trait’s page, a trait and an action being
edited, and its
actions with every status, and the time summary on each page and folded
away), the habit graph (all of it, and searched), a person’s page, Self’s habits, a habit’s page, how one of its scores
was reached and one being edited, Events (with each page of its day summary, folded away, events that
overlap,
what happened at an event, and a compaction proposal: the band, its
start, a note stepped to, its notes for Claude, and waiting for Claude; and
changes waiting to save: on the timeline, their sheet, one refused, and
an event waiting, opened), the Background updates page
and Notes.
It writes them, at 2x and with the real fonts (Roboto and Material
Icons, from the Flutter SDK), to build/screenshots/. This is the quick
way to see a UI change without running the app:
flutter test test/visual
The fonts come with the SDK but are downloaded only for a build or
flutter precache. Without them the tests fail rather than draw text as
blocks; flutter precache downloads them.
Some characters show as a box with an X in it, such as the → in an action
history’s “8h of 10h target → 82”. The sample data uses the arrow because
the server writes it in every measured rating’s explanation, so the app
must show it. The SDK’s Roboto doesn’t have it, and tests have no other
font to fall back on. The app itself shows it, since a phone or browser
finds it in another font.
Nothing is compared with images kept in the repo, so the repo doesn’t grow
with every UI change. Instead, to see what a change did, screenshot before
and after it, then diff them (this needs pip install pillow):
flutter test test/visual --dart-define=SCREENSHOTS_DIR=build/before # on main
flutter test test/visual --dart-define=SCREENSHOTS_DIR=build/after # on your branch
python3 tool/visual_diff.py build/before build/after build/changes
build/changes/index.html shows each changed screen’s before, after, and
their difference (changed pixels in red).
.github/workflows/visual.yml does this for every PR, against the PR’s
base branch, on Linux. The job summary lists the changed screens. The
visual-changes artifact has the images, and the screenshots artifact
has every screen as the PR draws it. It reports changes; it doesn’t fail
because a screen changed.
.github/workflows/emulator.yml also runs the Android app on an emulator
and checks that the home screen “+” brings the keyboard up
(tool/emulator/keyboard_test.sh), and that changes waiting to save are
handed off between the app and its WorkManager background task, each sent
once (tool/emulator/handoff_test.sh, running
integration_test/outbox_handoff_test.dart; on a device of your own,
flutter test integration_test/outbox_handoff_test.dart -d ).
test/outbox_handoff_test.dart checks the same in seconds, with two
outboxes standing for the app’s and the task’s, and what happens when one
is killed mid-request. Tests go on the emulator only when they need
Android itself (intents, home screen widgets, the system keyboard, a cold
start, WorkManager); anything else, animations included, is a Flutter
widget test, which is faster and can check every frame.
On a pull request, the slow checks (the Android build, the emulator and
the visual tests) skip a push that changes nothing they use: one that
only changes docs, say, or, after a push they passed, only bumps the
version (tool/ci/needs_run.sh). A skipped check counts as passed.
Versions
The version is in pubspec.yaml (e.g. 1.1.0+1), and the app shows it
under About in its menu. Only the part before the + is maintained by
hand: CI builds with its run number as the build number, so Android sees
each build as an update. Every pull request that changes the app bumps the
version, following semantic versioning (patch for
fixes, minor for features), and adds a section for it to CHANGELOG.md;
the version check (.github/workflows/version.yml) fails the PR
otherwise. Fixing it reruns only that check. Releases are titled with the
version, e.g. Time Tracker 1.1.0 (build 25).
App icon
The icon (a calendar page whose body is a clock, on teal) is drawn once in
tool/icon/generate.py, which writes every size and format: Android’s
adaptive and themed icons and their PNG fallbacks, the web favicon and PWA
icons, and the Windows .ico. To change it, edit the drawing there and run:
pip install pillow playwright && playwright install chromium
python3 tool/icon/generate.py
Layout
lib/
main.dart entry point; picks MCP or demo backend
models/note.dart the server's NotedTime
services/mcp_client.dart MCP over Streamable HTTP, with bearer auth
services/notes_repository.dart get_notes / note tools
auth/oauth.dart discovery, DCR, PKCE, token exchange/refresh
auth/auth_session.dart keeps tokens fresh, runs sign-in
auth/redirect_receiver.dart localhost listener (Windows) / deep link (Android)
auth/web_redirect_receiver.dart popup + web/callback.html (web)
auth/platform_receiver*.dart picks the receiver for the platform
auth/token_store.dart secure token storage
outbox/note_outbox.dart unsaved notes: retry, backoff, cancel
outbox/outbox_store.dart keeps them across restarts
outbox/background_sync.dart Android WorkManager task that sends them
platform/add_note_shortcut.dart taps on the home screen "+" (Android)
platform/google_calendar_source.dart the phone's Google calendars (Android)
services/google_calendars.dart which are shown, and each day's events
screens/notes_screen.dart uncompacted notes by day (saved and not), sign-in, "+"
widgets/note_dialog.dart new/edit note sheet: description, time, delete
android/app/src/main/kotlin/.../MainActivity.kt forwards the sign-in deep link
and home screen "+" taps
android/app/src/main/kotlin/.../AddNoteWidget.kt the home screen "+" widget
android/app/src/main/kotlin/.../GoogleCalendarReader.kt reads Android's
calendar provider
android/app/src/main/res/xml/shortcuts.xml the "Add note" app shortcut
Install
This server does not publish a one-line install command.
Open the repository installation guide