Skip to content

Commit 08b4536

Browse files
committed
refactor
1 parent 33fd79e commit 08b4536

15 files changed

Lines changed: 585 additions & 502 deletions

File tree

.github/workflows/docker-publish.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ name: Build and publish Docker image
33
on:
44
push:
55
branches: [ main ]
6+
paths:
7+
- 'app/**'
8+
- 'tracekit/**'
69
release:
710
types: [ published ]
811

PRIVACY.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Privacy Policy & Terms of Service
2+
3+
**Effective Date:** February 21, 2026
4+
5+
tracekit is a personal, self-hosted fitness data aggregation tool. This document describes
6+
how tracekit handles your data when you run it yourself or use a hosted instance operated
7+
by someone you trust.
8+
9+
---
10+
11+
## 1. What tracekit Is
12+
13+
tracekit is open-source software you install and operate yourself. It connects to third-party
14+
fitness platforms (Strava, Garmin, RideWithGPS, etc.) using OAuth tokens that **you** provide
15+
and stores a local cache of your activity metadata to power features like:
16+
17+
- Viewing your own activity history across providers in one calendar view
18+
- Matching the same activity across providers (e.g. Garmin upload → Strava record)
19+
- Updating activity titles and gear assignments on Strava
20+
21+
---
22+
23+
## 2. Data We Collect and Why
24+
25+
### What is stored
26+
tracekit stores **activity metadata only** — no GPS tracks, no route geometry, no heart-rate
27+
time series. The metadata stored includes:
28+
29+
- Activity name, type, date/time, and duration
30+
- Distance and elevation
31+
- Equipment / gear name
32+
- Heart-rate averages and max values
33+
- Temperature
34+
- Provider-assigned activity ID
35+
36+
### What is NOT stored
37+
- GPS coordinates, routes, or map data
38+
- Granular time-series sensor data (per-second HR, power, cadence streams)
39+
- Any data from other Strava users — only the authenticated user's own data is ever fetched or stored
40+
41+
### Why it is stored
42+
Data is cached locally (in a SQLite database you control) to avoid unnecessary API calls and
43+
to allow offline browsing of your own history. The cache is only ever read by you — the
44+
person running the software.
45+
46+
---
47+
48+
## 3. How Data Is Used
49+
50+
- **Matching:** Activity records from different providers are compared by timestamp and
51+
distance to identify duplicates and link IDs across providers.
52+
- **Display:** Your own cached activities are shown on the calendar and sync status pages.
53+
- **Writeback:** You may use tracekit to update activity titles and gear assignments on Strava
54+
via the Strava API. No other write operations are performed on third-party platforms.
55+
56+
tracekit **never**:
57+
58+
- Shares your data with any third party
59+
- Aggregates or analyzes data across multiple users
60+
- Uses your data for advertising or machine learning
61+
- Sells, licenses, or discloses your data to anyone
62+
63+
---
64+
65+
## 4. Third-Party Platforms
66+
67+
tracekit integrates with the following platforms. Your use of those platforms is governed by
68+
their own terms and privacy policies.
69+
70+
### Strava
71+
tracekit uses the [Strava API](https://www.strava.com/legal/api). Activity data obtained via
72+
the Strava API is used solely to display your own activities to you and to perform gear/title
73+
updates on your behalf. tracekit is not affiliated with or endorsed by Strava.
74+
75+
> Powered by Strava — [strava.com](https://www.strava.com)
76+
77+
### Garmin
78+
tracekit can ingest activity data that originated on Garmin devices, either via Garmin Connect
79+
or via Strava (where Garmin-sourced activities may appear). tracekit is not affiliated with or
80+
endorsed by Garmin.
81+
82+
> Powered by Garmin — [garmin.com](https://www.garmin.com)
83+
84+
### RideWithGPS
85+
tracekit can sync activities from RideWithGPS. tracekit is not affiliated with or endorsed by
86+
RideWithGPS.
87+
88+
---
89+
90+
## 5. Data Retention
91+
92+
- Cached activity data is retained locally in your SQLite database until you delete it.
93+
- You can delete all data for a provider at any time using the `reset` command or the settings
94+
page in the web app.
95+
- If you request deletion of your data from a hosted instance, the operator must delete all
96+
stored data related to your account within a reasonable time (we target 24 hours).
97+
- **Daily sync:** tracekit performs a daily background sync. If an activity has been deleted
98+
on a connected platform, tracekit will eventually remove it from the local cache. See the
99+
[TODO list](TODO.md) for planned improvements to deletion propagation.
100+
101+
---
102+
103+
## 6. Security
104+
105+
- OAuth tokens used to access third-party APIs are stored in your local configuration file.
106+
You are responsible for securing that file.
107+
- All communication with third-party APIs uses HTTPS.
108+
- tracekit does not expose your data to the internet unless you choose to run the web app on
109+
a public interface.
110+
111+
---
112+
113+
## 7. Your Rights
114+
115+
You have the right to:
116+
117+
- **Access** all data tracekit has stored about you (it is in your SQLite database file).
118+
- **Delete** all stored data — use `python -m tracekit reset` or the settings page.
119+
- **Revoke** tracekit's access to any connected platform at any time by disconnecting the app
120+
in that platform's settings (e.g. Strava's [Connected Apps](https://www.strava.com/settings/apps)
121+
page). Upon revocation, no further data will be fetched.
122+
123+
---
124+
125+
## 8. Changes to This Policy
126+
127+
This policy may be updated as tracekit's features evolve. The effective date at the top of
128+
this document reflects when it was last changed. Continued use of the software constitutes
129+
acceptance of the current policy.
130+
131+
---
132+
133+
## 9. Contact
134+
135+
tracekit is open-source software maintained on GitHub:
136+
<https://github.com/ckdake/tracekit>
137+
138+
For questions or deletion requests, open an issue or contact the repository maintainer.

TODO.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ Tracked here for visibility. PRs and issues welcome.
1818
- [ ] What about choochoo?
1919
- [ ] Load files from S3 or another remote source: [boto3](https://pypi.org/project/boto3/)
2020

21+
## Strava API Compliance
22+
23+
- [ ] **Delete synced Strava activities that have been deleted on Strava** — during daily sync,
24+
re-fetch the list of activity IDs for each synced month and remove any local records
25+
whose Strava ID no longer exists (Strava API §2.14.vi requires deletion within 48 hours).
26+
- [ ] **Implement Strava webhook subscriptions** for real-time deletion events
27+
(`DELETE` event type on `/push_subscriptions`) so deletions propagate immediately rather
28+
than waiting for the next daily sync. See
29+
[Strava Webhook Events API](https://developers.strava.com/docs/webhooks/).
30+
- [ ] **Handle token revocation gracefully** — detect 401 responses from Strava, mark the
31+
provider as disconnected, and prompt the user to re-authorize rather than failing silently.
32+
2133
## File Formats
2234

2335
- [ ] Get everything out of GPX files: [gpxpy](https://pypi.org/project/gpxpy/) *(basics in — fill out metadata, add fields to db)*

app/calendar_data.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,27 @@ def get_single_month_data(config: dict[str, Any] | None, year_month: str) -> dic
155155

156156
total_activities = sum(activity_counts.values())
157157

158+
# Collect Garmin device names used in this month (non-null, distinct)
159+
garmin_devices: list[str] = []
160+
try:
161+
rows = (
162+
GarminActivity.select(GarminActivity.device_name)
163+
.where(
164+
GarminActivity.start_time.is_null(False)
165+
& (GarminActivity.start_time >= start_ts)
166+
& (GarminActivity.start_time <= end_ts)
167+
& GarminActivity.device_name.is_null(False)
168+
)
169+
.distinct()
170+
)
171+
garmin_devices = sorted({r.device_name for r in rows if r.device_name})
172+
except Exception:
173+
pass
174+
175+
provider_metadata: dict[str, dict] = {}
176+
if garmin_devices:
177+
provider_metadata["garmin"] = {"devices": garmin_devices}
178+
158179
return {
159180
"year_month": year_month,
160181
"year": year_int,
@@ -165,6 +186,7 @@ def get_single_month_data(config: dict[str, Any] | None, year_month: str) -> dic
165186
"provider_status": provider_status,
166187
"activity_counts": activity_counts,
167188
"total_activities": total_activities,
189+
"provider_metadata": provider_metadata,
168190
}
169191
except Exception as e:
170192
return {"error": f"Database error: {e}"}

app/routes/pages.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
pages_bp = Blueprint("pages", __name__)
1111

1212

13+
@pages_bp.route("/privacy")
14+
def privacy():
15+
"""Privacy policy and terms of service page."""
16+
return render_template("privacy.html", page_name="Privacy Policy")
17+
18+
1319
@pages_bp.route("/settings")
1420
def settings():
1521
"""Settings page — edit providers, timezone and debug flag."""

app/static/calendar.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,34 @@ function renderGrid(yearMonth, data) {
3333
return;
3434
}
3535

36+
const PROVIDER_DISPLAY = {
37+
strava: { label: 'Strava', attr: '<a href="https://www.strava.com" target="_blank" rel="noopener" class="provider-attr attr-strava" title="Powered by Strava">Powered by Strava</a>' },
38+
garmin: { label: 'Garmin', attr: '<a href="https://www.garmin.com" target="_blank" rel="noopener" class="provider-attr attr-garmin" title="Powered by Garmin">Powered by Garmin</a>' },
39+
stravajson: { label: 'Strava (JSON)', attr: '<a href="https://www.strava.com" target="_blank" rel="noopener" class="provider-attr attr-strava" title="Powered by Strava">Powered by Strava</a>' },
40+
ridewithgps: { label: 'RideWithGPS', attr: '' },
41+
spreadsheet: { label: 'Spreadsheet', attr: '' },
42+
file: { label: 'File', attr: '' },
43+
};
44+
45+
const meta = data.provider_metadata || {};
46+
3647
grid.innerHTML = enabledProviders.map(p => {
3748
const synced = data.provider_status[p];
3849
const cls = synced ? 'synced' : 'not-synced';
3950
const tooltip = synced ? 'Synced' : 'Not synced';
4051
const count = data.activity_counts[p] || 0;
4152
const countHtml = count > 0 ? '<div class="activity-count">' + count + ' activities</div>' : '';
42-
return '<div class="provider-status ' + cls + '" title="' + tooltip + '"><div>' + p.substring(0, 8) + '</div>' + countHtml + '</div>';
53+
const info = PROVIDER_DISPLAY[p] || {};
54+
const label = info.label || p;
55+
const attrHtml = (synced && info.attr) ? '<div class="provider-attr-row">' + info.attr + '</div>' : '';
56+
57+
// Device names (currently Garmin-only)
58+
const devices = (synced && meta[p] && meta[p].devices) ? meta[p].devices : [];
59+
const deviceHtml = devices.length
60+
? '<div class="provider-devices">' + devices.map(d => '<span class="device-chip">' + d + '</span>').join('') + '</div>'
61+
: '';
62+
63+
return '<div class="provider-status ' + cls + '" title="' + tooltip + '"><div>' + label + '</div>' + countHtml + deviceHtml + attrHtml + '</div>';
4364
}).join('');
4465
}
4566

app/static/style.css

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,106 @@ select#timezone:focus {
693693
margin: 0 8px;
694694
color: rgba(0,0,0,.2);
695695
}
696+
.footer-attribution {
697+
display: inline;
698+
}
699+
/* Strava brand orange: #FC4C02 */
700+
.attr-strava {
701+
color: #FC4C02 !important;
702+
font-weight: 600;
703+
}
704+
.attr-strava:hover {
705+
color: #d93f00 !important;
706+
text-decoration: underline;
707+
}
708+
/* Garmin brand blue: #007CC3 */
709+
.attr-garmin {
710+
color: #007CC3 !important;
711+
font-weight: 600;
712+
}
713+
.attr-garmin:hover {
714+
color: #005f99 !important;
715+
text-decoration: underline;
716+
}
717+
718+
/* ── Provider attribution within calendar cards ─────────────────────────────── */
719+
.provider-attr-row {
720+
margin-top: 4px;
721+
font-size: 0.65rem;
722+
line-height: 1.2;
723+
}
724+
.provider-attr {
725+
text-decoration: none;
726+
font-weight: 600;
727+
border-radius: 3px;
728+
padding: 1px 4px;
729+
display: inline-block;
730+
}
731+
.provider-attr.attr-strava {
732+
color: #FC4C02;
733+
border: 1px solid rgba(252,76,2,.35);
734+
}
735+
.provider-attr.attr-strava:hover { background: rgba(252,76,2,.08); }
736+
.provider-attr.attr-garmin {
737+
color: #007CC3;
738+
border: 1px solid rgba(0,124,195,.35);
739+
}
740+
.provider-attr.attr-garmin:hover { background: rgba(0,124,195,.08); }
741+
742+
/* ── Device name chip inside provider card ───────────────────────────────────── */
743+
.provider-devices {
744+
margin-top: 4px;
745+
display: flex;
746+
flex-wrap: wrap;
747+
gap: 3px;
748+
}
749+
.device-chip {
750+
display: inline-block;
751+
font-size: 0.62rem;
752+
padding: 1px 5px;
753+
border-radius: 3px;
754+
background: rgba(0,124,195,.1);
755+
color: #005f99;
756+
font-weight: 500;
757+
white-space: nowrap;
758+
}
759+
760+
/* ── Privacy / prose page ────────────────────────────────────────────────────── */
761+
.prose {
762+
background: #fff;
763+
border-radius: 8px;
764+
padding: 32px 40px;
765+
box-shadow: 0 2px 10px rgba(0,0,0,.07);
766+
margin-bottom: 40px;
767+
line-height: 1.75;
768+
}
769+
.prose h1 { font-size: 1.7rem; margin-bottom: 4px; }
770+
.prose h2 { font-size: 1.2rem; margin-top: 32px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
771+
.prose h3 { font-size: 1rem; margin-top: 24px; color: #444; }
772+
.prose-lead { color: rgba(0,0,0,.4); font-size: 0.88rem; margin-top: 0; margin-bottom: 24px; }
773+
.prose hr { border: none; border-top: 1px solid #eee; margin: 28px 0; }
774+
.prose code {
775+
background: #f4f4f4;
776+
border-radius: 3px;
777+
padding: 1px 5px;
778+
font-size: 0.88em;
779+
}
780+
.prose ul { padding-left: 1.5em; }
781+
.prose li { margin-bottom: 4px; }
782+
.attribution-block { margin: 8px 0 16px; }
783+
.attribution-logo { height: 28px; vertical-align: middle; }
784+
.attribution-pill {
785+
display: inline-block;
786+
padding: 4px 12px;
787+
border-radius: 4px;
788+
font-weight: 600;
789+
font-size: 0.85rem;
790+
}
791+
.garmin-pill {
792+
background: #007CC3;
793+
color: #fff;
794+
text-decoration: none;
795+
}
696796

697797
/* ── Notifications ───────────────────────────────────────────────────────────── */
698798
.notif-wrap {

app/templates/base.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@
1616
<a href="https://github.com/ckdake/tracekit" target="_blank" rel="noopener">GitHub</a>
1717
<span class="footer-sep">&middot;</span>
1818
Non-commercial use only &mdash; see <a href="https://github.com/ckdake/tracekit/blob/main/LICENSE.txt" target="_blank" rel="noopener">license</a>
19+
<span class="footer-sep">&middot;</span>
20+
<a href="/privacy">Privacy Policy</a>
21+
<span class="footer-sep">&middot;</span>
22+
<span class="footer-attribution">
23+
<a href="https://www.strava.com" target="_blank" rel="noopener" class="attr-strava" title="Powered by Strava">Powered by Strava</a>
24+
<span class="footer-sep">&middot;</span>
25+
<a href="https://www.garmin.com" target="_blank" rel="noopener" class="attr-garmin" title="Powered by Garmin">Powered by Garmin</a>
26+
</span>
1927
</footer>
2028
<script>
2129
fetch('/api/recent-activity')

0 commit comments

Comments
 (0)