sugarcraft / candy-codetime
Privacy-first coding-time tracker — design antecedent Rtarun3606k/TakaTime, native PHP on the SugarCraft stack.
Package info
github.com/sugarcraft/candy-codetime
Type:project
pkg:composer/sugarcraft/candy-codetime
Requires
- php: ^8.3
- sugarcraft/sugar-async: dev-master
- sugarcraft/sugar-charts: dev-master
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-style: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.0
Suggests
- ext-sqlite3: Enables the optional --backend=sqlite storage backend (the default file backend needs no extension)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:00:28 UTC
README
CandyCodetime
Formerly published as
sugarcraft/sugar-tick(renamed 2026-10). Namespace changed:SugarCraft\Tick\→SugarCraft\CodeTime\.
candy-codetime — a privacy-first coding-time tracker for PHP 8.3+. Storage is
local by default: one append-only JSONL file per day, with an optional
embedded-SQLite backend (--backend=sqlite) for milestone tracking and indexed
queries. Nothing leaves your machine — no cloud, no daemon, and no database
server (SQLite is an in-process library reading a file you own). The
dashboard is a SugarCraft TUI that reads whichever backend you selected.
Install
composer global require sugarcraft/candy-codetime # the global bin dir varies by composer version and platform; ask composer: BIN="$(composer global config bin-dir --absolute)" "$BIN/candy-codetime" push myproj php src/Main.php 120
Or from a clone: composer install && php bin/candy-codetime.
Quick start
candy-codetime push myproj php src/Main.php 120 # record one heartbeat candy-codetime # open the dashboard candy-codetime export csv 30 # headless dump of 30 days
php examples/dashboard.php seeds a throwaway data directory with a pretend
week and opens the dashboard, so you can see the TUI before recording
anything real.
Commands
| Command | Arguments | What it does |
|---|---|---|
dashboard |
— | Open the TUI (reads the selected backend). Default when no command is given. Without an interactive terminal it refuses with a pointer to export instead of hanging. |
push |
<project> <language> <file> [duration] |
Record one heartbeat. duration is whole seconds (default 60); a non-numeric value is rejected, not silently read as 0. |
milestone |
<add|list> [name] [description] |
Manage release markers. Milestones live only in SQLite, so this command forces the sqlite backend regardless of --backend; the dashboard never displays them. |
export |
<csv|json|ics> [days] |
Dump heartbeats for the last N days (default 7, clamped to [1, 3660]). |
gaps |
[days] |
List untracked stretches of at least 300 seconds within the last N days (default 7). |
backup |
— | Archive day files (*.jsonl) older than 30 days into <dir>-backups/. Copies only — it never deletes your data. The SQLite database is not archived: a byte copy of a live .db is a torn snapshot, so sqlite users must use sqlite3 <db> ".backup <dest>" or VACUUM INTO '<dest>' (the CLI prints this reminder when a .db is present). |
help, --help, -h |
— | Print usage. |
--version, -v |
— | Print the version. |
Options (accepted before or after the command):
| Option / env | Effect |
|---|---|
--backend=file|sqlite |
Storage for push/export/gaps/dashboard (default: file). Both forms work: --backend=sqlite and --backend sqlite. The sqlite backend needs the sqlite3 PHP extension; without it the CLI exits with a clear message instead of crashing. |
CANDY_CODETIME_DIR=<path> |
Data directory (default $XDG_DATA_HOME/candy-codetime, falling back to ~/.local/share/candy-codetime — note that an unset HOME resolves to ./.local/share/…). |
A storage failure ends any command with a one-line message on stderr and exit status 1 — never a bare PHP fatal.
Legacy SUGARTICK_* environment variables are still read (with a deprecation
notice) and the old default data directory is migrated once on first launch.
CLI messages follow LC_ALL/LC_MESSAGES/LANG across the 16 shipped
locales.
Ignoring files
A .sugartrackignore in the current directory — or, failing that, in the
tracked file's own directory (the first found wins) — holds glob patterns one
per line; a push whose file matches is reported as skipped and never
stored. A bare pattern (secret.log) matches by file name at any depth; a
path pattern (vendor/*) matches below any vendor directory, whether the
tracked path is relative or absolute.
Trust boundary: ignore files usually live inside tracked repositories, so
their patterns are untrusted input to the tracking tool. A wildcard-only
pattern (a bare *) matches every file name and silently zeroes that
project's entire recorded time. When the loaded file carries such a pattern,
the skip is no longer a quiet stdout line: push reports it on stderr
with the ignore file's path, its pattern count, and the affected project —
then still exits 0, because the opt-out is honored, not refused. To undo a
repo-supplied plant, delete or narrow the file the notice names; to shadow a
directory-level plant, run push from a working directory whose own
.sugartrackignore (checked first) holds only honest patterns.
Data layout
$CANDY_CODETIME_DIR/
2026-10-23.jsonl one appendable line per heartbeat
candy-codetime.db created only by --backend=sqlite
<dir>-backups/ created by `backup`
Each line of a day file is one JSON object:
{"time": <unix seconds>, "project": <string>, "language": <string>, "file": <absolute path>, "duration": <seconds>, "tags": <array>}. tags is always
empty for now — no CLI command sets one yet, and readers should ignore
unknown keys.
Day files are keyed by the machine's local calendar date, so history
written while crossing time zones stays filed under the date it was tracked.
Nothing is ever deleted: beats accumulate until you remove them by hand or
archive them with backup (which copies and never trims).
Fixing a bad entry
There is no edit command, and that is deliberate — the store is plain files.
With the file backend, close the dashboard (or press r afterwards), fix or
drop the offending JSON line in the day file, and reload. With the sqlite
backend, open the database with sqlite3 and fix the row in heartbeats.
Privacy
The data directory and every file in it are owner-private: directories are
0700, day files and the database are 0600, created under a narrowed umask
so they are never world-readable for an instant — and a directory or file left
loose by an older version is tightened on the next write (if that fails, the
write aborts rather than storing your paths in the open — and every
print/echo of a stored project or file name goes through a control-byte
strip, so a doctored store cannot paint escape sequences into your terminal).
The backup archive
under <dir>-backups/ is held to the same contract: it is created 0700 and
every copied day file is tightened to 0600, because an archive of those files
is the same path map one directory over. Heartbeats carry absolute file paths
and project names; treat the directory accordingly.
Colour is driven by the detected ColorProfile for the terminal: a profile
below 256-colour (which NO_COLOR=1, CLICOLOR=0 and TERM=dumb all land
on) renders the dashboard without any tints, and a resize or capability
report mid-session updates it live.
Keys (dashboard)
| Key | Action |
|---|---|
← |
Move the window back one day |
→ |
Move it forward one day |
r |
Reload from disk |
q / Esc |
Quit |
Ctrl+C |
Quit |
The two ranking panes render side by side from 62 columns up (two 30-cell frames plus a gap) and stack vertically below that. Every dashboard line is clipped to the terminal width — frames fill it exactly, nothing wraps into garbage on narrow shells, and days that cannot be drawn are counted ("+N earlier") rather than silently dropped.
Architecture
| File | Role |
|---|---|
Heartbeat |
Value object — one activity sample (project, language, file, duration, tags). |
Store |
JSONL-backed reader/appender; implements HeartbeatReader; enforces the private modes above. |
Storage\SqliteBackend |
Optional indexed storage; implements HeartbeatReader; also owns milestones. |
Storage\HeartbeatReader |
The read contract (loadRange/invalidate) both storages satisfy — what lets every subcommand honour --backend uniformly. |
Stats |
Pure fold — totals per project / language + per-day timeline buckets. |
Dashboard |
SugarCraft Model — window shifts one day per press (←/→), reload (r), quit; reads through a HeartbeatReader. |
Renderer |
View — header + ranking panes + Sparkline timeline (SugarCharts); colour-ladder aware (honours the detected ColorProfile). |
Report\GapsReport |
Pure gap detection over sorted heartbeats (threshold 300 s). |
Backup\AutoBackup |
Copy-only archiver; throws loudly on any failed copy. |
Ignore\SugarTrackIgnore |
.sugartrackignore matcher used by push. |
Milestone |
Value object for sqlite markers. |
Export\CsvExporter / JsonExporter / IcalExporter |
export formats behind ExporterInterface (the tabular pair shares TabularExporterInterface). |
Support\Version |
Single-source version constant. |
Lang |
i18n facade (16 locales under lang/). |
The Store is intentionally append-only at the file level, so editor plugins can
>> echo a JSON line directly without coordinating with the dashboard — the
trade-off is honest: raw >> redirection does not take the advisory lock
push uses, so a hand-echoed line could theoretically interleave; plugin
integrations should flock or just call push. The dashboard re-reads on
launch and on r; it has no clock subscription, so an open panel never
refreshes by itself.
Shared foundations
candy-codetime uses sugar-async
for cooperative cancellation: Store::append() polls an optional
CancellationToken immediately before writing (PHP cannot interrupt a blocking
write mid-flight). The CLI never arms a token; the seam exists for embedding
hosts that want abortable writers.
Test
composer install vendor/bin/phpunit
Credits & inspiration
Design antecedent: Rtarun3606k/TakaTime; SugarCraft is developed as a native PHP project.
