All notable changes to Row Tracker are documented here.
Format follows Keep a Changelog; versioning follows
Semantic Versioning — while the major version is 0, minor bumps may
include breaking changes (.env keys, schema, etc.), same as any other pre-1.0 project.
History below 0.9.0 is backfilled from commit history at the point versioning was introduced —
these releases weren't tagged contemporaneously, but the groupings and dates reflect what actually shipped.
- Two more Insights Milestones: total strokes taken and peak heart rate. New
stroke_countandheart_rate_maxcolumns, captured on every sync going forward. Neither has a work/rest split to worry about — C2 doesn't report rest-period strokes or a separate rest heart rate, so both are plain per-session values (total strokes is a whole-session sum; peak HR is the highest single reading across history, with the date it happened). backfill_strokes_hr.py— same live-API backfill pattern asbackfill_rest_meters.py, for the same reason (most historical workouts here were CSV-imported, no local raw_json to read this from). Unlike badges/journeys, Insights milestones are computed fresh on every page load rather than earned-once-and-locked, so there's nothing to retroactively correct here.
- Two new Insights Milestones: total workouts logged and total calories burned. Lifetime counterparts to the existing "years rowing" and "hours on the erg" cards, same "always-true fun fact" treatment (gated only on having enough history, no significance test). Concept2 has no rest-period calorie figure anywhere in its API, so total calories is a plain sum — no work/rest split to worry about there, unlike distance and time.
- New
rest_time_secondscolumn, mirroringrest_distance_meters(0.11.0). Concept2 tracks rest-interval time separately from work-interval time the same way it does distance —Workout.total_time_seconds(work + rest) now feeds the "hours on the erg" milestone, so a session with light rowing between intervals counts all of it as real time spent rowing. Pace and PBs stay on work-onlytime_seconds, untouched, same reasoning as the meters fix. backfill_rest_meters.pynow backfills bothrest_distance_metersandrest_time_secondsin the same live-API pass.
- GitHub Releases are now created automatically on every version tag push
(
.github/workflows/publish-image.yml), with notes pulled straight from that version's CHANGELOG.md section. Previously a tag push only built the GHCR image — the Release itself was always a manual step, and it quietly stopped happening after v0.10.4 (v0.10.5 through v0.11.1 shipped with tags but no Releases until this was noticed and backfilled).
- CHANGELOG.md was missing the
## [0.10.4]header — the Dependabot entry had been folded invisibly into[0.10.5]'s section since it shipped.
C2ApiClient.get_results()was reading the wrong pagination key (meta.last_pageinstead of the realmeta.pagination.total_pages), so it silently stopped after the first 100 results on every sync. Nightly incremental syncs rarely hit that ceiling so it went unnoticed, but a historical backfill or a sync recovering from a long outage would have quietly dropped everything past page 1. Fixed, and now covered by a regression test.- Added retries (3 attempts, short backoff) for transient C2 API failures — its results endpoint returns occasional bare 500s in normal operation, seen firsthand in production, and one of those was tripping the nightly sync's failure alert for no real reason.
backfill_rest_meters.py(see 0.11.0) now sources rest-distance values from a live re-fetch of the full C2 history (matched by workout ID) instead of localraw_json— most historical workouts here were CSV-imported and never hadraw_jsonlocally, so the previous approach only recovered data for ~150 of 2559 workouts. Re-running it against the live API on top of this release backfilled the rest correctly.
- Interval workout "rest" meters (light rowing between intervals) were silently dropped from
every lifetime/volume total. Concept2 tracks rest-interval distance as a separate
rest_distancefield, apart from each interval's owndistance— the C2 sync only ever readdistance, so none of it ever made it into Row Tracker. Discovered by comparing against the Concept2 website's "Lifetime Meters" figure, which includes it. Newrest_distance_meterscolumn (models.py) captured on every sync going forward;backfill_rest_meters.pybackfills it fromraw_jsonfor existing workouts and retroactively corrects the earned dates of the five volume badges and one virtual journey that had already crossed their thresholds under the old, undercounted totals. - Pace, personal bests, single-piece test results, and CAWR/training-load are unaffected — they were already, and remain, based on work-interval distance/time only, so a slower recovery split can never inflate a time or a PB.
- Workout detail page now shows rest-interval meters alongside the main distance stat when a session had any.
- Pre-built multi-arch Docker images, published to GHCR on every version tag
(
.github/workflows/publish-image.yml) —linux/amd64andlinux/arm64(Raspberry Pi and other ARM boards), tagged with the version,major.minor, andlatest. This is an additional way to run Row Tracker, not a replacement —docker-compose.ymlstill defaults tobuild: .so the homelab deploy flow (deploy.sh) is unaffected. Self-hosters who'd ratherdocker compose pullthan build locally can swap inimage: ghcr.io/dsubtle1/row-tracker:latest— documented in the README under "Using the pre-built image instead of building locally."
- Dependabot (
.github/dependabot.yml) — weekly automated PRs for outdated pip dependencies (requirements.txt/requirements-dev.txt), the Docker base image, and the GitHub Actions added in 0.10.2. Every Dependabot PR gets checked by the same CI workflow as any other PR, so a bump that breaks something fails the check instead of merging silently.
- CI workflow pinned
actions/checkout@v4andactions/setup-python@v5— the first run flagged both as being forced onto a deprecated Node.js runtime. Bumped to the current majors (@v7/@v7).
- CI: GitHub Actions now runs the full test suite on every push to
mainand every PR (.github/workflows/tests.yml) — 228 tests, Python 3.11 to match the Docker base image, pip dependency caching. The test suite has existed for a while but nothing ran it automatically; now a red check on a PR means something needs a look before merge. Status badge added to the top of the README, alongside the license badge. CONTRIBUTING.mdupdated to mention the automated check.
- "Insights" was missing from the mobile nav drawer — it was added to the desktop nav when the Insights page shipped (0.9.5) but never added to the hamburger menu, so it was invisible on phones/tablets ever since. Added, and covered by a new regression test that diffs the desktop and mobile nav link sets so a future addition can't silently repeat this.
- The v0.9.12 timezone fix didn't actually fix the root cause.
scheduler.pyhas always passedtimezone="America/Toronto"toBackgroundScheduler, which looks correct — but that setting does not propagate to a job'sCronTriggerunless the trigger is also given an explicit timezone. Every nightlyCronTrigger(hour=3, ...)call was silently falling back to the container's OS clock instead, which is exactly the bug v0.9.12'sTZenv var papered over by making the OS clock coincidentally correct. EveryCronTriggernow gets the timezone explicitly (sourced from theTZenv var, defaulting toAmerica/Toronto), so the schedule is correct regardless of the container's OS timezone. - A bad or expired C2 API token was indistinguishable from "nothing new to sync."
get_results()caught 401s and network failures internally and just returned an empty list — identical to a genuinely successful call that found zero new workouts.C2ApiClientnow tracks the actual failure reason (last_error) andsync_workouts()surfaces it as a real error instead of a silent no-op. - The manual Sync button's frontend only treated
status: "error"as a failure, missing the"partial"state the/syncroute already returns when a sync completes with errors — a partially-failed sync showed as a plain success in the UI. Now shown as a failure with the actual error message.
- "Last synced" indicator on the Dashboard, next to the Sync button — shows how long ago the
last successful sync ran, or a clear warning if the most recent attempt failed. Backed by a new
SyncStatustable, updated by both the nightly scheduler and manual syncs. - Email alerts on scheduled-job failure. The nightly sync, PB recalc, badge evaluation, and
backup jobs previously only logged their own failures — now they also email
NOTIFY_EMAIL(same address badge/milestone notifications already use), so a broken job doesn't sit unnoticed until someone happens to check container logs.
- The nightly 3:00 AM scheduler (sync, PB recalc, badge eval, backup) was actually running at
3:00 AM UTC, not 3:00 AM local time as the FAQ/Quick Start have always documented ("3:00 AM
Toronto time"). The container had no timezone configured, so it silently defaulted to UTC —
for anyone east of Greenwich in winter or west of it generally, that's several hours off from
the documented time, and any workout logged in that gap wouldn't sync until the following
night. Added a
TZvariable to.env.example(defaults toAmerica/Toronto, matching the docs) — set it to your own IANA zone if you're elsewhere. Existing deployments need to addTZ=to their own.envand restart — see the updated README.
- Chart.js now loads from a CDN with Subresource Integrity, closing the one blind spot left
from the earlier security review: the vendored copy had no version pinned anywhere and no way
to know if it went stale. It's now pinned to Chart.js 4.4.1 with a
sha384SRI hash verified against the actual bytes jsDelivr serves — a tampered or compromised CDN response would simply fail to execute rather than run silently. - The local copy stays as an automatic fallback (
window.Chart || document.write(...)) so Row Tracker's "no external dependencies required" promise holds even fully offline — an air-gapped homelab or a CDN outage falls back to the same file the service worker already pre-caches for offline PWA use. The fallback file is now byte-identical to the pinned CDN version (previously a different, unverified build had been hand-vendored). - All five chart-rendering templates now share one partial (
_chart_cdn.html) instead of duplicating the script tag, with the exact commands to regenerate the hash and fallback file documented inline for the next version bump.
- Upgraded the Docker image's build toolchain and OS packages. A vulnerability scan of the
built image found known CVEs in the base image's bundled
pip/setuptools/wheel(andjaraco-context, a pip dependency) — none had ever been upgraded past whatever version shipped with thepython:3.11-slimbase image. The Dockerfile now explicitly upgrades them before installing app dependencies, and runsapt-get upgradefor OS-level packages so future rebuilds pick up Debian's security patches automatically. This resolved every fixable finding from the scan (1 HIGH, 1 HIGH, 5 MEDIUM/LOW). The remaining findings are in Debian OS utilities (chieflyperl, present in every Debian-based image) with no upstream fix published yet, and are not reachable through the application's own code — Row Tracker never shells out to any OS binary.
- Insights volume figures now read in consistent kilometres. The year-over-year and
weekly-volume cards rendered distances as an ambiguous "k m" hybrid (e.g.
1,996k m,56.0k m/week) that read like a typo. They now show clean kilometres (1,996 km,56.0 km/week), including the pill and sparkline labels.
RUN_SCHEDULERenv flag (defaulttrue). Set it tofalseon a secondary or development instance so it doesn't run the nightly Concept2 sync, PB recalc, badge evaluation, and backup — and doesn't fire duplicate notification emails — alongside the instance that owns your live data. Documented in.env.example.
- Brand logo throughout the app. The circular rower emblem now sits in the nav bar (desktop and mobile) in place of the placeholder emoji, and the full "ROW TRACKER" lockup anchors the top of the Dashboard as a theme-switched hero — the dark-mode artwork in dark mode, the light-mode artwork in light mode. Logos were processed to transparent backgrounds so they sit cleanly on any surface, and both versions share an identical frame so switching themes causes no size shift.
- Milestones section on the Insights page — all-time-highlight cards rendered as a big-number treatment: years rowing (with session count), biggest single day, total hours on the erg, and longest unbroken streak. These are facts rather than patterns, so they carry no confidence tag and appear once there's a real history behind them.
- Year-over-year volume insight — compares meters logged Jan 1 → today against the identical span of last year, so progress (or a lull) shows up as it happens.
- Pace-trend insight now measures steady pieces only (20 min+). Trending pace across all workout types was confounded by changes in workout mix — more sprints or more easy volume could masquerade as a pace change. Restricting to steady work makes the trend mean what it says.
- Tuned insight surfacing against real data: day-of-week, rest-gap, and seasonal pace effects were left gated (the underlying signal is genuinely flat at the median, so loosening thresholds would have manufactured noise) while the new milestone and year-over-year rules add substance that the data actually supports.
- Insights page — a new nav section that reads your whole history and surfaces
patterns in plain language (best day of the week, rest-day effect, pace and volume
trends, fastest stroke rate in steady pieces, session-length clusters, consistency,
PB clustering). Each insight clears a minimum-sample and significance check before it
appears and is tagged Strong pattern or Early signal; the strongest carry a
recommendation, some linking into the WOD generator. Implemented as a deterministic,
rule-based engine (
insights_engine.py) that runs entirely on your server. - Optional AI "coach's read" (
insights_ai.py, gated byUSE_AI_INSIGHTS=true+ANTHROPIC_API_KEY, off by default) — a short first-person synthesis at the top of the Insights page. It only rephrases the facts the engine already computed and never invents a number; the cards render identically without it.
- Manual Sync (and any other POST) no longer fails after the page has been open a while.
CSRF tokens carried a default 1-hour time limit, so clicking "Sync workouts" on a
long-open dashboard returned
400 request failed — check logswith aCSRF token has expiredlog line. The time limit is now disabled; tokens stay session-bound, which is the actual CSRF protection.
Closes out the pre-public-release audit started in 0.9.2.
- Rewrote git history to remove a leaked Gmail App Password that had been present in
.env.examplefrom 2026-06-14 to 2026-07-23 (the value itself was already revoked before this fix). Every commit SHA from that point forward changed as a result — this repo's history was force-pushed once as part of this fix. No other secrets were found anywhere in history.
CONTRIBUTING.md— how to report bugs/features, the fork→branch→PR flow, dev setup, and the doc-sync convention this codebase follows. Linked from the README.- GitHub topics for discoverability:
self-hosted,concept2,rowing,ergometer,flask,docker,python,fitness-tracker,homelab.
- README claimed AI coaching was "the only feature that talks to a third party" — inaccurate,
since the Feedback button also emails the developer directly (recipient is hardcoded, not
.env-configurable). Added a dedicated Feedback section spelling out exactly what it does and doesn't send. - Removed dead code in
c2_api.py:_persist_refresh_token()had no callers (Concept2 issues a non-expiring bearer token, so nothing ever rotates it) and wouldn't have worked reliably even if called — it wrote to.envinside the container, which isn't a mounted file and is now correctly excluded from the image entirely. Also fixed the module docstring, which still described an OAuth token-exchange flow the code never actually implements.
- Untracked
designidea.webp(unreferenced design-reference image) and two internaldocs/superpowers/AI-agent planning docs — kept locally, gitignored, consistent with the existingrow-tracker-spec.md/docs/redesign-spec.mdconvention.
Fixes from a pre-public-release audit. The remaining items from that audit (a leaked credential in git history, a couple of untracked-file cleanup questions) required user decisions and are closed out in 0.9.3 above.
.envwas being baked directly into the built Docker image — no.dockerignoreexisted, soCOPY . .copied the real, secret-filled.envfile (and the entire.githistory) into every image layer. The app never actually reads that in-image copy (all config comes fromos.environ, populated by Compose'senv_fileat container start), so excluding it is purely a fix, not a behavior change. Added.dockerignoreexcluding.env,.git/,data/,csv-data/, caches, and other build-irrelevant paths.- README's clone command still had the placeholder
yourusernameinstead of the realdsubtle1— first-time visitors couldn't copy-paste it correctly. LICENSE.mdand the README's embedded license text had mismatched copyright-name casing (dSubtle1vsdsubtle1).
- Removed
python-dotenvfromrequirements.txt— never actually imported anywhere; the app reads config exclusively viaos.environ. - Minor doc-sync polish: added
VERSION/CHANGELOG.mdto the README's Project Structure tree, normalized a wording mismatch betweenQUICKSTART.mdand its in-app twin ("Start a Journey" → "Start a Virtual Journey").
- The version display moved from a centered line at the bottom of page content to a small,
low-opacity
vX.Y.Zbadge fixed to the bottom-right corner of the viewport on every page
- The service worker's static-asset cache (
CACHE_NAME) was a fixed string that never changed across deploys, so any CSS/JS update was invisible to a browser that had already loaded the app once — cache-first meant it just kept serving the old file forever.sw.jsis now rendered from a Jinja template withCACHE_NAMEtied toapp_version, so every version bump automatically invalidates the old cache instead of silently serving stale static assets
- AI-assisted WOD coaching narrative — optional Claude Haiku-generated warm-up/cool-down/coaching
notes, feature-flagged via
USE_AI_WOD+ANTHROPIC_API_KEY; falls back to the static rule-based text automatically if disabled or unavailable - Versioning:
VERSIONfile, this changelog, and the version now shown in the site footer and FAQ page - Explicit self-hosting/privacy statement in the README — no Row Tracker backend, your own Concept2 and Anthropic credentials, your own data
- Removed the Support/sponsorship section from the README for now
- Email notifications for newly earned badges, lifetime-metres milestones, and virtual journey completions
(
NOTIFY_EMAIL, defaults toMAIL_USERNAME)
- Refreshed README screenshots and fixed stale documentation
- PWA install support — manifest, app icons, service worker for offline-capable static asset caching
- Nightly automated SQLite database backups (30-day retention) via APScheduler
- Data export — workout history and personal bests as CSV or JSON
- Route test coverage across all four blueprints
- Distinct icon + progress bar per badge
- Clickable journey waypoints with a details popup and Wikipedia photo banner
- Badge
earned_datedefaulting to today instead of the actual earned date - Journey map label overlap and mobile scaling
- Grainy waypoint banner images (now uses the original file, not a rescaled thumbnail)
- Dependency bump to close known CVEs
- Vendored Chart.js locally instead of loading from a CDN
- WOD History rewritten as a month-by-month calendar (
/wod/history,/api/wod/day) - Journey Map teaser card on the dashboard
- Dashboard redesign (Phase R2) — circular lifetime-metres gauge, pace/volume sparklines, "Your Progress" journey checklist
- Badges never being earnable —
seed_badges()was never actually called on startup
- Full pytest suite for engine modules (PBs, badges, WOD generation)
- Real planned-session tracking for the Iron Month badge
- Per-stroke pace/stroke-rate visualization on the workout detail page
- UI-driven CSV import for pre-API-access seasons
- CSRF protection on every state-changing route
- Personal best delta (improvement-vs-previous) tracking bug
- Redesign polish (Phase R1) — light mode and teal accent consistency
SECRET_KEYfallback behavior
- README screenshots, license, and formatting refresh
- Replaced a leaked Gmail app password in
.env.examplewith a placeholder
- Mobile-responsive layout
- Heart rate data on workout detail (min/avg/max, zone classification)
- Chart improvements
- Initial release: dashboard, Concept2 Logbook sync, personal bests, in-app feedback form, FAQ and Quick Start guide