How long each app was actually in the foreground — on a Windows PC and your Android phones — kept on your own machine, and built to survive a Windows reset.
Screenshots show synthetic data from npm run demo:seed.
See it on the project site: how it works, every chart it draws (live, on invented data) and every feature.
- Local only. A dashboard on your own PC, reachable from your LAN. No cloud, no account, no telemetry, no outbound requests.
- Real foreground time, not process uptime. On Windows, a sampler records which window has focus and whether the session is locked. On Android, the phone's own usage events are reconstructed into sessions and screen-on time.
- Survives a reset. The database lives on a non-system drive and is backed
up with SQLite's
backup(). A recovery kit and a reset drill prove that a wipedC:\would cost nothing. - Honest numbers. Every figure was checked against something external,
such as Android's Digital Wellbeing. The traps found along the way are
recorded in
docs/DEVLOG.md.
Each page top to bottom, at 1440px wide, in the default dark theme. A
Dark / Light / System menu in the top bar switches every page to a light
theme; each image has a light twin beside it (*-light.png), taken in the
same run from the same demo, so the two show the same numbers. Retake them
all with npm run demo:shots.
Overview: today, the daily trend, a six-month heat map, the top apps and the most opened by day, the shape of the day, and where every minute went.
By App: ranked by time and by opens, in each app's brand colour, above the full app table.
App detail: one app's total, opens, typical session and longest session, per day and per hour, and the paths it was merged from.
Activity: the heat map, expanded to every recorded day.
Sync Status: whether the sampler is running, what is stored, and every ingest run.
Overview: screen-on time and unlocks, the trend, the heat map, the top apps and the most opened by day, and how much of it any app accounts for.
By App: ranked by time and by opens, above every app the phone reported.
App detail: one app, per day and per hour.
Activity: the phone's heat map, expanded.
Sync Status: how far back Android's history reaches, and every push from the phone.
Windows PC Android phone(s)
---------- ----------------
sampler.ps1 (logon task, unelevated) Screen Time Reporter (APK)
polls the foreground window every 2 s reads UsageStatsManager events
-> daily JSONL, crash-safe -> gzipped JSON over the LAN,
| every few hours
v hourly task / "Sync now" |
ingest-windows.ts ------------------+ +---------+ POST /api/android/ingest
v v (bearer token)
SQLite on a NON-system drive
+ a backup() copy after every write
|
v
Next.js dashboard on :7844 (shared password)
It is really two different measurements, and the headline figure is computed
differently for each. On Windows the sampler produces an exclusive timeline, so
per-app time adds up to the total. On Android, per-app time adds up to only
about 0.76 of screen-on time, because the lock screen, the launcher and system
surfaces claim the rest. So the phone's headline figure comes from screen-on
events, never from a sum over apps. docs/DEVLOG.md records
why, with the measurements.
- Windows 10 or 11 for the dashboard and the PC collector.
- Node.js 22.16 or newer. The project uses Node's built-in
node:sqlite, so there are no native modules to compile. - A drive that is not the system drive, such as
D:\, for the database. The code refuses to put it onC:\, since surviving a reset is the point. - Optional, for phones: Android 8.1 or newer, and JDK 17 or Android Studio to build the APK. Below Android 9 a phone records no screen-on or unlock events, so its dashboard shows time in apps instead of screen time.
npm ci
cp .env.example .env.local # set DASHBOARD_PASSWORD to anything
npm run demo:seed # synthetic laptop + phone, written to demo/
npm run build
npm run demo # http://localhost:7849npm ci
cp config/collector.example.json config/collector.json
cp .env.example .env.local-
In
config/collector.json, pointdatabasePath,backupPath,scratchDirandsamplerLogDirat your non-system drive, and setdeviceLabelto what you call this PC. -
In
.env.local, setDASHBOARD_PASSWORDandANDROID_INGEST_TOKENto two different long random values, for example:node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))"
Then build it and run it at every logon:
npm run build
npm run autostart -- -RunNowIt serves on http://localhost:7844. The first build takes a minute. After
that, the logon task rebuilds whenever the source is newer than the build.
start-screen-time-dashboard.bat and stop-dashboard.bat do the same by hand.
It installs as an app. In Edge or Chrome on the PC, open
http://localhost:7844 and use the install icon in the address bar, or
Apps > Install this site as an app. It opens in its own window with the
clock icon. A phone reaching it over the LAN can only add a home-screen
shortcut that opens in the browser: installing needs HTTPS or localhost,
and the dashboard is plain HTTP on the LAN.
powershell -ExecutionPolicy Bypass -File scripts\install-sampler.ps1 -RunNowThis registers two unelevated tasks: the sampler, at logon, and the ingest, hourly. Windows keeps no usable history of foreground time, so recording starts from the moment the sampler runs.
Or let Screen Time Native record. If the laptop runs
Screen Time Native, the
standalone Windows app ported from this project's laptop half, run only one
sampler: two record every second twice. Register just the ingest with
install-sampler.ps1 -IngestOnly (it stops a running sampler cleanly), run
npm run ingest once more, then set nativeDatabasePath and nativeFrom in
config/collector.json (see the example file). From nativeFrom, a local
hour edge, the ingest copies the laptop's screen time from that app's
database, and Sync Status reads its sampler's heartbeat. Earlier history
stays as this project's sampler recorded it.
-
Download the signed APK from the Releases page and install it; Android will ask you to allow installs from your browser or file manager. It is not on the Play Store.
Or build it yourself:
cd android ./gradlew assembleDebugand install
app/build/outputs/apk/debug/app-debug.apk, for example withadb install. A self-built copy is signed with a different key, so it cannot install over the released one, or the other way round, without uninstalling first. Uninstalling also resets the phone's device id, so its next sync shows up as a second phone; fold the old entry's history into it withnpm run android:merge -- --from <old-slug> --into <new-slug>(it reports first, and writes only with--apply). To sign your own release builds, pointandroid/keystore.properties(gitignored) at your keystore withstoreFile,storePassword,keyAliasandkeyPassword, then run./gradlew assembleRelease. -
In the app, tap Grant beside Usage access and enable Screen Time Reporter.
-
Enter the dashboard's address (
http://<your-PC's-LAN-IP>:7844) andANDROID_INGEST_TOKEN, then tap Save and test connection.
The phone syncs in the background. Android keeps about 10 days of event history, so the first sync backfills that much. If the dashboard has not received the phone's history for 168 hours (sooner on a phone that keeps less), the app posts a notification, before the oldest of it is lost; allow notifications when it asks.
On its own the phone posts straight to the dashboard, so it must be on the
PC's network, the PC must be on, and the address changes whenever the router
hands the PC a new one. A cloud inbox removes all three: the phone posts
to a fixed https:// address on any network, and the PC collects whatever is
waiting on its hourly ingest and on Sync now. With only ~10 days of
history on the phone, that matters more here than for bytes.
The inbox cannot read what it holds. The app encrypts each upload to a
key only the PC has (P-256 ECDH, AES-256-GCM), with your ingest token mixed
into the key derivation, so the inbox cannot forge one either. It sees an
upload's size, its time and the phone's random device id. The format is
written out at the top of src/lib/inbox.ts.
It runs on Supabase's free tier as one Edge Function and two tables, in
supabase/. The function also serves the sibling Data Usage Tracker, each
app as its own mailbox, so one free project covers both.
- Create a Supabase project (or reuse the sibling's). Run
supabase/migrations/*.sqlin order in its SQL editor, and deploysupabase/functions/inboxwith JWT verification off (the function checks its own tokens):supabase functions deploy inbox --no-verify-jwt. - In
config/collector.json, set"inbox": { "url": "https://<project>.supabase.co/functions/v1/inbox/screen-time", "keyPath": "D:\\keys\\inbox-key.pem" }. Keep the key offC:\and out of the repo, and back it up: envelopes still in the inbox cannot be opened without it. - Create the key and the PC's own token, then register both tokens:
Run the SQL that the last one prints in the SQL editor. It holds SHA-256 digests only.
npm run inbox -- keygen npm run inbox -- drain-token npm run inbox -- sql
- Rebuild and restart the dashboard. On the phone, at home, tap Set up
from the dashboard. The app fetches the inbox address and the PC's key
over the LAN (never from the inbox, which could swap the key) and shows
the key's fingerprint;
npm run inbox -- keyprints the same one.
An upload that came this way shows on the phone's Sync page with when it
reached the database. One that cannot be opened stays in the inbox, marked
failed; fix the cause, then npm run inbox -- drain --retry-failed, or tap
Full resync on the phone.
npm run backup:kit # the code, the secrets and the local-only files, off C:\
npm run drill # restore into TEMP, verify, and list anything a reset would lose
npm run restore # after a reset: bring the database back from the backupRe-run backup:kit after changing config, logos or secrets. The drill fails
if the copy is out of date.
No logos ship with the repository: they are other companies' trademarks. Drop
your own into public/apps_logo/, and brand colours into
config/app-colours.json. Both are gitignored. Until then, every app shows its
initial. See public/apps_logo/README.md.
The dashboard is single-user, over plain HTTP on your LAN. Every page sits behind one password, which fails closed, and wrong guesses are throttled. Sessions are signed with a key derived from the password, and the phone uses its own token. The pages send a strict Content-Security-Policy.
Because it listens on every network interface, run this once from an Administrator shell to block the dashboard on Public networks. Mark your home network Private first, or the phone will be cut off.
npm run firewallThe optional cloud inbox is the one outbound connection. It carries only the
phones' uploads, encrypted on the phone to a key the inbox never sees, and is
off unless inbox is set in config.
Read SECURITY.md for the threat model and its limits, and for
how to report a vulnerability.
The pages are built to WCAG 2.1 AA. Contrast is measured, every page has its own title, every chart has a text summary, and there's a skip link and screen-reader navigation state. Two deliberate exceptions:
- Phones get the desktop layout, zoomed to fit, rather than a reflowed narrow one (WCAG 1.4.10). Pinch-zoom still works.
- The heat map's quietest shades sit close to the background. The heat map has a text summary, and every cell has a title.
npm run dev # dev server on :7844. Stop the logon-task server first:
# the two share .next.
npm run typecheck
npm run selftest # no device or database needed
npm run drill # the reset drill, against your real backupCI runs the typecheck, the self-test, a production build, a PowerShell 5.1 check and the APK build on every push.
Before changing anything, read docs/DEVLOG.md. It is
the day-by-day design record: every measurement behind a decision, and every
trap that was found by running things rather than by reasoning about them.
scripts/research/ holds the measurements that shaped the design. Nothing
there is needed to run the tracker.
This is a personal project. Issues are welcome; see CONTRIBUTING.md. For pull requests, please open
an issue first, since the design is deliberately narrow.
MIT © 2026 Naimul Nashid. App names and logos belong to their owners. None are distributed here.