CT

cranthony/time-tracker-client

Developer tools
0 stars 0 forks Quality 56 Trend 20

A 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)

  1. Install Flutter. Follow for your OS. When flutter doctor runs clean for the platform you want, you’re done.

  2. Clone this repo and fetch packages:

    git clone https://github.com/cranthony/time-tracker-client.git
    cd time-tracker-client
    flutter pub get
    
  3. Point it at your server. Copy config.example.json to config.json (it’s gitignored) and set MCP_URL to 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: /mcp from the origin’s root, mcp from 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, set MSYS_NO_PATHCONV=1 first, or it rewrites /mcp into a Windows path before Flutter sees it.

    If you skip --dart-define-from-file=config.json in 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 no MCP_URL. With one, the app talks to your server and ignores SAMPLE_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 (R in the terminal), starts again from the sample data. A hot reload (r) keeps them.
  • Without SAMPLE_DATA=true the 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 doctor should 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 in config.json.
  • The server’s MCP_CORS_ALLOWED_ORIGINS set to https://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_URL is 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):

  1. Install rclone (winget install Rclone.Rclone) and make a remote called gdrive, signing in to Google in the browser it opens:

    rclone config create gdrive drive scope=drive.file
    

    drive.file lets it see only the files rclone itself makes, never the rest of your Drive.

  2. Store its config (it holds the sign-in token) as the DRIVE_RCLONE_CONF secret in both repositories; rclone config file says 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-mcp
    

    Pipe it rather than passing it with --body: --body would put the token on the command line, where other processes can see it.

  3. The packages go to TimeTracker at the top of your Drive. For another folder, set the repository variable DRIVE_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:

  1. Create the app in the Play Console: Time Tracker, app, free.
  2. Upload the first bundle by hand. Google’s API can’t create an app’s first release. Download time-tracker.aab from 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.
  3. 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:
    gh secret set PLAY_SERVICE_ACCOUNT_JSON < key.json
    
    Until this secret exists, the workflow skips the Play upload.

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-licenses and 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:

  1. It reads /.well-known/oauth-protected-resource/mcp from the server to find AuthKit.

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

  3. It runs the authorization-code flow with PKCE in your browser. It asks for a token for the server’s URL (the resource parameter), which is the audience the server checks.

  4. 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” / ClientException loading notes: the server refused the cross-origin call. Serve the app from localhost, or add its origin to the server’s MCP_CORS_ALLOWED_ORIGINS.
  • Signed in, but loading notes fails with HTTP 401: the token’s audience doesn’t match. MCP_URL must 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
View this README on GitHub

Install

This server does not publish a one-line install command.

Open the repository installation guide