trainbud 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/CHANGELOG.md +675 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +182 -0
- package/README.md +322 -0
- package/dist/appDb.d.ts +83 -0
- package/dist/appDb.d.ts.map +1 -0
- package/dist/appDb.js +239 -0
- package/dist/appDb.js.map +1 -0
- package/dist/check.d.ts +12 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +230 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +457 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +61 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +241 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard.d.ts +8 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +1004 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/dashboardCharts.d.ts +99 -0
- package/dist/dashboardCharts.d.ts.map +1 -0
- package/dist/dashboardCharts.js +317 -0
- package/dist/dashboardCharts.js.map +1 -0
- package/dist/dashboardData.d.ts +89 -0
- package/dist/dashboardData.d.ts.map +1 -0
- package/dist/dashboardData.js +228 -0
- package/dist/dashboardData.js.map +1 -0
- package/dist/detect/baseline.d.ts +17 -0
- package/dist/detect/baseline.d.ts.map +1 -0
- package/dist/detect/baseline.js +62 -0
- package/dist/detect/baseline.js.map +1 -0
- package/dist/detect/countdown.d.ts +30 -0
- package/dist/detect/countdown.d.ts.map +1 -0
- package/dist/detect/countdown.js +79 -0
- package/dist/detect/countdown.js.map +1 -0
- package/dist/detect/detectors.d.ts +20 -0
- package/dist/detect/detectors.d.ts.map +1 -0
- package/dist/detect/detectors.js +291 -0
- package/dist/detect/detectors.js.map +1 -0
- package/dist/detect/findings.d.ts +29 -0
- package/dist/detect/findings.d.ts.map +1 -0
- package/dist/detect/findings.js +2 -0
- package/dist/detect/findings.js.map +1 -0
- package/dist/detect/forecast.d.ts +33 -0
- package/dist/detect/forecast.d.ts.map +1 -0
- package/dist/detect/forecast.js +122 -0
- package/dist/detect/forecast.js.map +1 -0
- package/dist/detect/index.d.ts +50 -0
- package/dist/detect/index.d.ts.map +1 -0
- package/dist/detect/index.js +114 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/sleepQuality.d.ts +21 -0
- package/dist/detect/sleepQuality.d.ts.map +1 -0
- package/dist/detect/sleepQuality.js +164 -0
- package/dist/detect/sleepQuality.js.map +1 -0
- package/dist/detect/trimp.d.ts +22 -0
- package/dist/detect/trimp.d.ts.map +1 -0
- package/dist/detect/trimp.js +77 -0
- package/dist/detect/trimp.js.map +1 -0
- package/dist/detect/week.d.ts +31 -0
- package/dist/detect/week.d.ts.map +1 -0
- package/dist/detect/week.js +184 -0
- package/dist/detect/week.js.map +1 -0
- package/dist/garmin/auth.d.ts +10 -0
- package/dist/garmin/auth.d.ts.map +1 -0
- package/dist/garmin/auth.js +81 -0
- package/dist/garmin/auth.js.map +1 -0
- package/dist/garmin/cache.d.ts +42 -0
- package/dist/garmin/cache.d.ts.map +1 -0
- package/dist/garmin/cache.js +141 -0
- package/dist/garmin/cache.js.map +1 -0
- package/dist/garmin/client.d.ts +22 -0
- package/dist/garmin/client.d.ts.map +1 -0
- package/dist/garmin/client.js +237 -0
- package/dist/garmin/client.js.map +1 -0
- package/dist/garmin/daily.d.ts +24 -0
- package/dist/garmin/daily.d.ts.map +1 -0
- package/dist/garmin/daily.js +96 -0
- package/dist/garmin/daily.js.map +1 -0
- package/dist/garmin/garminApiTypes.d.ts +70 -0
- package/dist/garmin/garminApiTypes.d.ts.map +1 -0
- package/dist/garmin/garminApiTypes.js +3 -0
- package/dist/garmin/garminApiTypes.js.map +1 -0
- package/dist/garmin/garminConnect.d.ts +17 -0
- package/dist/garmin/garminConnect.d.ts.map +1 -0
- package/dist/garmin/garminConnect.js +5 -0
- package/dist/garmin/garminConnect.js.map +1 -0
- package/dist/garmin/partial.d.ts +32 -0
- package/dist/garmin/partial.d.ts.map +1 -0
- package/dist/garmin/partial.js +38 -0
- package/dist/garmin/partial.js.map +1 -0
- package/dist/garmin/rawApi.d.ts +59 -0
- package/dist/garmin/rawApi.d.ts.map +1 -0
- package/dist/garmin/rawApi.js +113 -0
- package/dist/garmin/rawApi.js.map +1 -0
- package/dist/garmin/types.d.ts +85 -0
- package/dist/garmin/types.d.ts.map +1 -0
- package/dist/garmin/types.js +11 -0
- package/dist/garmin/types.js.map +1 -0
- package/dist/history/capture.d.ts +12 -0
- package/dist/history/capture.d.ts.map +1 -0
- package/dist/history/capture.js +63 -0
- package/dist/history/capture.js.map +1 -0
- package/dist/history/context.d.ts +51 -0
- package/dist/history/context.d.ts.map +1 -0
- package/dist/history/context.js +122 -0
- package/dist/history/context.js.map +1 -0
- package/dist/history/fallback.d.ts +72 -0
- package/dist/history/fallback.d.ts.map +1 -0
- package/dist/history/fallback.js +185 -0
- package/dist/history/fallback.js.map +1 -0
- package/dist/history/ingest.d.ts +44 -0
- package/dist/history/ingest.d.ts.map +1 -0
- package/dist/history/ingest.js +347 -0
- package/dist/history/ingest.js.map +1 -0
- package/dist/history/scheduler.d.ts +20 -0
- package/dist/history/scheduler.d.ts.map +1 -0
- package/dist/history/scheduler.js +54 -0
- package/dist/history/scheduler.js.map +1 -0
- package/dist/history/schema.d.ts +41 -0
- package/dist/history/schema.d.ts.map +1 -0
- package/dist/history/schema.js +91 -0
- package/dist/history/schema.js.map +1 -0
- package/dist/history/store.d.ts +112 -0
- package/dist/history/store.d.ts.map +1 -0
- package/dist/history/store.js +282 -0
- package/dist/history/store.js.map +1 -0
- package/dist/httpServer.d.ts +55 -0
- package/dist/httpServer.d.ts.map +1 -0
- package/dist/httpServer.js +1027 -0
- package/dist/httpServer.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/mcpConfig.d.ts +56 -0
- package/dist/mcpConfig.d.ts.map +1 -0
- package/dist/mcpConfig.js +106 -0
- package/dist/mcpConfig.js.map +1 -0
- package/dist/pairApi.d.ts +14 -0
- package/dist/pairApi.d.ts.map +1 -0
- package/dist/pairApi.js +38 -0
- package/dist/pairApi.js.map +1 -0
- package/dist/paths.d.ts +45 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +105 -0
- package/dist/paths.js.map +1 -0
- package/dist/profile.d.ts +123 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +327 -0
- package/dist/profile.js.map +1 -0
- package/dist/promptApi.d.ts +43 -0
- package/dist/promptApi.d.ts.map +1 -0
- package/dist/promptApi.js +328 -0
- package/dist/promptApi.js.map +1 -0
- package/dist/promptSuggestions.d.ts +16 -0
- package/dist/promptSuggestions.d.ts.map +1 -0
- package/dist/promptSuggestions.js +141 -0
- package/dist/promptSuggestions.js.map +1 -0
- package/dist/selfTest.d.ts +53 -0
- package/dist/selfTest.d.ts.map +1 -0
- package/dist/selfTest.js +225 -0
- package/dist/selfTest.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +67 -0
- package/dist/server.js.map +1 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +210 -0
- package/dist/setup.js.map +1 -0
- package/dist/toolErrors.d.ts +2 -0
- package/dist/toolErrors.d.ts.map +1 -0
- package/dist/toolErrors.js +18 -0
- package/dist/toolErrors.js.map +1 -0
- package/dist/tools/activities.d.ts +18 -0
- package/dist/tools/activities.d.ts.map +1 -0
- package/dist/tools/activities.js +182 -0
- package/dist/tools/activities.js.map +1 -0
- package/dist/tools/bodyComposition.d.ts +10 -0
- package/dist/tools/bodyComposition.d.ts.map +1 -0
- package/dist/tools/bodyComposition.js +139 -0
- package/dist/tools/bodyComposition.js.map +1 -0
- package/dist/tools/context.d.ts +8 -0
- package/dist/tools/context.d.ts.map +1 -0
- package/dist/tools/context.js +148 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/findings.d.ts +7 -0
- package/dist/tools/findings.d.ts.map +1 -0
- package/dist/tools/findings.js +51 -0
- package/dist/tools/findings.js.map +1 -0
- package/dist/tools/heartRate.d.ts +10 -0
- package/dist/tools/heartRate.d.ts.map +1 -0
- package/dist/tools/heartRate.js +105 -0
- package/dist/tools/heartRate.js.map +1 -0
- package/dist/tools/index.d.ts +66 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +101 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/payloads.d.ts +148 -0
- package/dist/tools/payloads.d.ts.map +1 -0
- package/dist/tools/payloads.js +2 -0
- package/dist/tools/payloads.js.map +1 -0
- package/dist/tools/recovery.d.ts +31 -0
- package/dist/tools/recovery.d.ts.map +1 -0
- package/dist/tools/recovery.js +350 -0
- package/dist/tools/recovery.js.map +1 -0
- package/dist/tools/sleep.d.ts +10 -0
- package/dist/tools/sleep.d.ts.map +1 -0
- package/dist/tools/sleep.js +124 -0
- package/dist/tools/sleep.js.map +1 -0
- package/dist/tools/stress.d.ts +11 -0
- package/dist/tools/stress.d.ts.map +1 -0
- package/dist/tools/stress.js +108 -0
- package/dist/tools/stress.js.map +1 -0
- package/dist/tools/trainingInsights.d.ts +15 -0
- package/dist/tools/trainingInsights.d.ts.map +1 -0
- package/dist/tools/trainingInsights.js +79 -0
- package/dist/tools/trainingInsights.js.map +1 -0
- package/dist/tools/types.d.ts +12 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/vo2Max.d.ts +11 -0
- package/dist/tools/vo2Max.d.ts.map +1 -0
- package/dist/tools/vo2Max.js +110 -0
- package/dist/tools/vo2Max.js.map +1 -0
- package/dist/tools/week.d.ts +12 -0
- package/dist/tools/week.d.ts.map +1 -0
- package/dist/tools/week.js +74 -0
- package/dist/tools/week.js.map +1 -0
- package/dist/usage.d.ts +104 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +247 -0
- package/dist/usage.js.map +1 -0
- package/dist/utils/batch.d.ts +2 -0
- package/dist/utils/batch.d.ts.map +1 -0
- package/dist/utils/batch.js +18 -0
- package/dist/utils/batch.js.map +1 -0
- package/dist/utils/helpers.d.ts +32 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/dist/utils/helpers.js +188 -0
- package/dist/utils/helpers.js.map +1 -0
- package/dist/utils/jsonFile.d.ts +19 -0
- package/dist/utils/jsonFile.d.ts.map +1 -0
- package/dist/utils/jsonFile.js +24 -0
- package/dist/utils/jsonFile.js.map +1 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +97 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/secretFile.d.ts +15 -0
- package/dist/utils/secretFile.d.ts.map +1 -0
- package/dist/utils/secretFile.js +47 -0
- package/dist/utils/secretFile.js.map +1 -0
- package/dist/utils/stdio.d.ts +10 -0
- package/dist/utils/stdio.d.ts.map +1 -0
- package/dist/utils/stdio.js +70 -0
- package/dist/utils/stdio.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/dist/watchApi.d.ts +264 -0
- package/dist/watchApi.d.ts.map +1 -0
- package/dist/watchApi.js +333 -0
- package/dist/watchApi.js.map +1 -0
- package/package.json +77 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [Unreleased] — server 0.5.0 · watch 2.0.0
|
|
6
|
+
|
|
7
|
+
### Security — the key stopped living in the address bar
|
|
8
|
+
|
|
9
|
+
- **The dashboard trades `?token=` for a session cookie and redirects to a clean
|
|
10
|
+
URL.** The API key opened the dashboard and then stayed in the address bar for
|
|
11
|
+
the whole session, which put it in browser history, in the tunnel provider's
|
|
12
|
+
access log, and in every screenshot of the page — and this page is about to be
|
|
13
|
+
screenshotted by people announcing it. The token still gets you in; what carries
|
|
14
|
+
you afterwards is an opaque 32-byte session id held in the server's memory, so it
|
|
15
|
+
grants nothing after a restart and cannot be replayed against another server.
|
|
16
|
+
`HttpOnly` keeps page script off it and `SameSite=Lax` is what stops cookie auth
|
|
17
|
+
from adding the CSRF that a Bearer header never had. `Secure` goes on only when
|
|
18
|
+
the request arrived over TLS, because a browser silently discards a Secure cookie
|
|
19
|
+
on `http://127.0.0.1` — which is how the local dashboard is reached.
|
|
20
|
+
A Bearer header still works untouched, and is what the watch and MCP clients use.
|
|
21
|
+
- **The paired watch holds the API key itself, and the docs now say so.** Approving
|
|
22
|
+
a pairing hands the watch the same credential that opens the dashboard and `/mcp`,
|
|
23
|
+
not a narrower per-device token. The privacy policy implied a scoped token; it now
|
|
24
|
+
states what is actually handed over and how to revoke it. A per-device token is
|
|
25
|
+
still owed.
|
|
26
|
+
- **`scripts/start-watch-stack.ps1` no longer defaults to the maintainer's own ngrok
|
|
27
|
+
domain.** Anyone who cloned the repo and ran the script published a tunnel pointed
|
|
28
|
+
at somebody else's address. It reads `TRAINBUD_NGROK_DOMAIN` and refuses to guess.
|
|
29
|
+
|
|
30
|
+
### Changed — the README a launch link lands on
|
|
31
|
+
|
|
32
|
+
- Quick start leads with `npx trainbud setup`; the clone-and-link path moves into a
|
|
33
|
+
"from source" fold. The old first instruction was four commands and a caveat.
|
|
34
|
+
- A **"What TrainBud is not"** section: no hosted service, not an official Garmin
|
|
35
|
+
integration, not MFA-compatible, and the AI features bill to your own key.
|
|
36
|
+
- The test count said 33. It is 551.
|
|
37
|
+
|
|
38
|
+
TrainBud knew a great deal about your body and nothing about you. This release
|
|
39
|
+
adds the half that was missing, and takes four decisions off the watch that it
|
|
40
|
+
was never in a position to make.
|
|
41
|
+
|
|
42
|
+
### Added — a profile, and a product that knows who it is talking to
|
|
43
|
+
|
|
44
|
+
- **`src/profile.ts`** — name, units, primary sport, weekly goal, the bands at
|
|
45
|
+
which a number turns amber or red, the watch card order, and the AI's voice.
|
|
46
|
+
One row of `app.db`, read by every surface. Reads salvage field by field
|
|
47
|
+
(a settings row that is not JSON is wreckage, not configuration, and
|
|
48
|
+
`getProfile` sits on the path of every watch fetch); writes are strict and
|
|
49
|
+
name the field they refused.
|
|
50
|
+
- **Personal thresholds decide colour everywhere.** Resting heart rate is graded
|
|
51
|
+
on the distance from *your own* median rather than on the rate — 58 bpm is
|
|
52
|
+
unremarkable for one person and a warning for another.
|
|
53
|
+
- **Card order and visibility** are set in the dashboard and live on the watch's
|
|
54
|
+
next fetch. No Connect IQ settings sync, no store update.
|
|
55
|
+
|
|
56
|
+
### Added — what the AI costs, which nothing had ever counted
|
|
57
|
+
|
|
58
|
+
- Every AI call records its tokens and its price. Month-to-date spend is a
|
|
59
|
+
**calendar month**, not a rolling thirty days: a cap on a sliding window never
|
|
60
|
+
resets, and the number is compared against a bill that runs in months.
|
|
61
|
+
- An optional monthly cap **refuses the request before the money is spent**, and
|
|
62
|
+
says so on the watch instead of after a round trip. No cap is set by default,
|
|
63
|
+
so nothing is ever blocked out of the box.
|
|
64
|
+
- **An unknown price is null, never zero.** A model this build has no rate for
|
|
65
|
+
records its tokens and leaves the cost unknown, because a call priced at zero
|
|
66
|
+
is a cap that can never trip. Every total carries the count of calls it could
|
|
67
|
+
not price and says when it is a floor rather than a total.
|
|
68
|
+
|
|
69
|
+
### Added — the Ask menu you write yourself
|
|
70
|
+
|
|
71
|
+
Up to five questions, set in the dashboard, that lead the Ask menu on the wrist in
|
|
72
|
+
the order you put them — ahead of the generated ones, in every state, including the
|
|
73
|
+
first fortnight when the app has nothing of its own worth asking about. Whatever
|
|
74
|
+
slots are left still fill from what actually fired, so "Why is my resting HR up?"
|
|
75
|
+
keeps its place on the day it is up.
|
|
76
|
+
|
|
77
|
+
**`ai.customPrompts` had existed since the profile landed and nothing read it.** It
|
|
78
|
+
was in the schema, it validated, it saved — and no dashboard control wrote it and no
|
|
79
|
+
code path consumed it, so a question typed against that API could never have appeared
|
|
80
|
+
on a watch. The bounds it shipped with said as much: eight questions for a five-slot
|
|
81
|
+
menu, 120 characters for a line the watch draws at 32. Both now come from
|
|
82
|
+
`promptSuggestions.ts`, which owns the menu, and the profile refuses to store a
|
|
83
|
+
question that could not be shown.
|
|
84
|
+
|
|
85
|
+
### Added — a training dashboard
|
|
86
|
+
|
|
87
|
+
- Phone first, because the pairing flow already was. Findings, the week against
|
|
88
|
+
last week, resting heart rate and sleep against your own median, spend, and
|
|
89
|
+
every setting above.
|
|
90
|
+
- Drawn from local SQLite and nothing else — no Garmin round trip, so it paints
|
|
91
|
+
instantly and works with an expired session. Charts are inline SVG built by
|
|
92
|
+
pure functions; there is no CDN, because a script tag would tell a third party
|
|
93
|
+
every time you opened your own health dashboard.
|
|
94
|
+
- **A missing day is a gap, not a zero.** Lines break at an absence, days are
|
|
95
|
+
placed by date rather than by array position, and a chart with nothing to draw
|
|
96
|
+
says so instead of rendering an empty axis that reads as "zero, every day".
|
|
97
|
+
|
|
98
|
+
### Added — local feature counters
|
|
99
|
+
|
|
100
|
+
Which of the nine cards anyone opens, and how often the Ask path runs. Counted
|
|
101
|
+
on a request the watch was already making rather than per swipe. On by default
|
|
102
|
+
because there is no endpoint to send them to; switchable off, with a delete
|
|
103
|
+
button, in the dashboard under Privacy.
|
|
104
|
+
|
|
105
|
+
### Changed — the watch draws state, it no longer decides it
|
|
106
|
+
|
|
107
|
+
`recoveryColor`, `sleepColor`, `stressColor` and `heartRateColor` are gone from
|
|
108
|
+
the Monkey C. The server grades; the watch colours. That removed three defects
|
|
109
|
+
rather than moving code: the Overview grid graded two of its four cells and
|
|
110
|
+
recovered the number by re-parsing its own formatted string (`parseNumber("6.3h")`
|
|
111
|
+
returns 6); `heartRateColor` painted one line holding both the resting and the
|
|
112
|
+
maximum heart rate while grading only the resting one; and a missing value fell
|
|
113
|
+
through to white, which is also what "graded and unremarkable" looks like.
|
|
114
|
+
|
|
115
|
+
### Fixed — on the wrist
|
|
116
|
+
|
|
117
|
+
- A finding that does not fit is **counted, not dropped**. The same payload
|
|
118
|
+
showed two findings on a Fenix and one, silently, on a Forerunner 55.
|
|
119
|
+
- The recovery ring no longer strikes through the heart-rate line beneath it.
|
|
120
|
+
A circle narrows as it descends; the label had been tested against that since
|
|
121
|
+
1.3.2 and the line under it never was.
|
|
122
|
+
- Severity is a marker, not the text colour. A warning arrived as five lines of
|
|
123
|
+
red on black — the least legible combination there is on a transflective
|
|
124
|
+
screen in daylight.
|
|
125
|
+
- A big **fall** in training load is marked. Only a rise was, so a 40% collapse
|
|
126
|
+
— the shape of an illness week — passed without a mark.
|
|
127
|
+
- Colours are chosen against the worst screen in the manifest. The Forerunner 55
|
|
128
|
+
has an eight-colour palette and rounds everything to the nearest entry, which
|
|
129
|
+
is why "good" is `#4CD964`: the mint the dashboard shipped with rounds to CYAN
|
|
130
|
+
there.
|
|
131
|
+
|
|
132
|
+
### Fixed — verification
|
|
133
|
+
|
|
134
|
+
The Forerunner 55 could not finish the screen tour. It died partway through,
|
|
135
|
+
twice, reported as "keypress did not register" — which reads exactly like the
|
|
136
|
+
simulator being flaky, and was not: the tour allocated a second full payload
|
|
137
|
+
while the first was still live, and on that device it is the difference between
|
|
138
|
+
fitting and not. All 28 states now capture on fr55 and fenix847mm.
|
|
139
|
+
|
|
140
|
+
### Fixed — store screenshots the app actually drew
|
|
141
|
+
|
|
142
|
+
The Connect IQ listing still carried five 1.3.x captures: an Overview grid from
|
|
143
|
+
before all four cells were graded, and findings as coloured body text rather
|
|
144
|
+
than white text with a severity marker. Replacing them had already failed twice
|
|
145
|
+
in one day — once because the screen tour paints its state counter over the app
|
|
146
|
+
("9/28" above the title, in five images that reached the tree), once because the
|
|
147
|
+
crop was measured inwards from the simulator window instead of from the device,
|
|
148
|
+
so it sat off-centre and carried the watch case, and the vendor's wordmark
|
|
149
|
+
printed on it, into a picture meant to be the screen.
|
|
150
|
+
|
|
151
|
+
- **`build.ps1 -Screens -NoLabel`** compiles the counter out through a
|
|
152
|
+
`ScreenTour.labelVisible()` annotation pair, so no keypress can bring it back.
|
|
153
|
+
- **`capture-sim.ps1 -Display -Device <id>`** locates the device artwork in the
|
|
154
|
+
window and adds `display.location` from the SDK's own `simulator.json`,
|
|
155
|
+
writing the display at native resolution — 390×390 on fr70 — with the corners
|
|
156
|
+
a round screen cannot physically show blacked out. If the artwork is not
|
|
157
|
+
there at 1:1 it refuses, rather than cropping something plausible.
|
|
158
|
+
- **`capture-store-shots.ps1`** drives the tour, refuses any build but the
|
|
159
|
+
label-free one, re-checks every saved image for the counter's colour, and
|
|
160
|
+
stops the run when a keypress does not land instead of naming every remaining
|
|
161
|
+
file after the state before it.
|
|
162
|
+
|
|
163
|
+
`ciq/store/screenshots/store/` is now Today, Week, Recovery, Overview and Ask as
|
|
164
|
+
2.0.0 draws them. The five whole-watch 558x558 images that sat beside them are
|
|
165
|
+
gone: nothing referenced them, they were 1.3.x too, and they were pictures of a
|
|
166
|
+
watch rather than of a screen.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## [0.4.1] — server 0.4.1 · watch 1.4.0
|
|
171
|
+
|
|
172
|
+
"The AI says it has no access to my data" was reported once and fixed once, in
|
|
173
|
+
`bb1fae8`. This release is what an audit found behind it: **four more distinct
|
|
174
|
+
causes of that same sentence**, none of which the first fix touched, plus six
|
|
175
|
+
defects the sweep turned up alongside them. Every one was verified against this
|
|
176
|
+
machine's real install, whose Garmin session is genuinely gone and whose record
|
|
177
|
+
genuinely stops on 2026-08-21 — which turned out to be the most valuable test
|
|
178
|
+
fixture in the project.
|
|
179
|
+
|
|
180
|
+
### Fixed — the AI could not see the user's data, four more ways
|
|
181
|
+
|
|
182
|
+
- **The one tool that reads the store told the model the store was empty.**
|
|
183
|
+
`get_findings` and `trainbud findings`, on a store holding 74 days:
|
|
184
|
+
*"Still gathering data — 74 of the 14 days needed."* `coverage.ready` carries
|
|
185
|
+
two refusals that need opposite answers — not enough history yet, and plenty
|
|
186
|
+
of history that stops weeks ago — and `bb1fae8` taught exactly one of the four
|
|
187
|
+
surfaces to tell them apart. `FindingsPayload.coverage` was also typed
|
|
188
|
+
`{ days, ready }` while the value assigned to it carried four fields, so the
|
|
189
|
+
renderer could not have read `throughDate` had it wanted to. One
|
|
190
|
+
`describeFindingsCoverage`, read by all four surfaces.
|
|
191
|
+
|
|
192
|
+
- **Nothing on the read path had ever opened the history store.** Every
|
|
193
|
+
per-metric tool read Garmin and nothing else, so an expired session meant
|
|
194
|
+
`get_sleep_data` threw, all six of the watch summary's payload calls returned
|
|
195
|
+
null, and an MCP client got a tool error — with 598 measurements and 2139
|
|
196
|
+
archived Connect responses on disk. Tools now fall back to `raw_payload`,
|
|
197
|
+
re-run through the *same* mapper the live path uses so a stored night keeps
|
|
198
|
+
its stage breakdown, and to `daily_metric` behind it for days older than the
|
|
199
|
+
180-day archive. **The window moves too**: with the record ending 08-21, "the
|
|
200
|
+
last 7 nights" selects seven days that were never recorded, so serving the
|
|
201
|
+
store changed nothing until the window could slide to where the data is — and
|
|
202
|
+
say so, in capitals, so no model reads a fortnight-old week as this week.
|
|
203
|
+
|
|
204
|
+
- **A dependency printed English into the middle of the MCP protocol stream.**
|
|
205
|
+
`garmin-connect` calls `console.log('login page title:', …)` on every
|
|
206
|
+
re-login, and `console.log` writes to stdout, which is the JSON-RPC channel
|
|
207
|
+
for `trainbud start`. Claude Desktop and Cursor — the two clients `setup`
|
|
208
|
+
configures — lose the session on any re-login inside a tool call. Stdout is
|
|
209
|
+
now claimed for the life of the process rather than one more `console` method
|
|
210
|
+
being patched at one more call site.
|
|
211
|
+
|
|
212
|
+
- **Any CLI command killed the answer the watch was waiting for.**
|
|
213
|
+
`reconcilePromptJobs` ran inside `getDb()`, so the first time *any* process
|
|
214
|
+
opened `app.db` it flipped every in-flight job to *"The server stopped before
|
|
215
|
+
this answer came back."* Running `trainbud doctor` while the watch waited did
|
|
216
|
+
exactly that. It now runs once, from `serve`, and never touches a job younger
|
|
217
|
+
than five minutes.
|
|
218
|
+
|
|
219
|
+
### Fixed — numbers the app stated and had not measured
|
|
220
|
+
|
|
221
|
+
- **"This week against last week" compared seven days against eight.**
|
|
222
|
+
`series(kind, n)` spans `n + 1` days inclusive, so `WEEK_DAYS * 2` split at
|
|
223
|
+
`today - 7` gave halves of 7 and 8. The TRIMP load line beside it summed two
|
|
224
|
+
correct seven-day windows, so a single card disagreed with itself.
|
|
225
|
+
|
|
226
|
+
- **The sleep card called a fortnight-old night "last night".**
|
|
227
|
+
`hours.slice(-7)` is the last seven *rows*, which are seven nights only if the
|
|
228
|
+
watch was worn every night. Windowed by date now, like `detectors.ts` and
|
|
229
|
+
`week.ts` already were; a week with nothing in it reports no debt rather than
|
|
230
|
+
a debt of zero.
|
|
231
|
+
|
|
232
|
+
- **`status` reported 1590 empty days for a store covering 366 dates.** The
|
|
233
|
+
count was over `ingest_day` rows, which are keyed (date, source) across six
|
|
234
|
+
sources. Now 292 empty days against 74 measured — figures that add up.
|
|
235
|
+
|
|
236
|
+
### Fixed — security and data loss
|
|
237
|
+
|
|
238
|
+
- **The crash handler wrote the API key to the log the request logger had
|
|
239
|
+
stopped writing.** The dashboard authenticates with `?token=`, and
|
|
240
|
+
`logger.error({ error, url: req.url })` was raw. The occurrence already
|
|
241
|
+
present in `.trainbud/mcp.log` has been scrubbed. **Rotate the key**: a
|
|
242
|
+
credential that has sat in a world-readable file is exposed whatever the file
|
|
243
|
+
says now.
|
|
244
|
+
|
|
245
|
+
- **Only `app.db` was hardened to 0600.** `history.db` — a year of sleep, heart
|
|
246
|
+
rate and HRV — `cache.db` and the log file were all at the default 0644.
|
|
247
|
+
|
|
248
|
+
- **`setup` rewrote the user's Claude Desktop config keeping only our own key.**
|
|
249
|
+
`readMcpConfig` returned `{ mcpServers }` and the write dropped everything
|
|
250
|
+
else; a config with no `mcpServers` key at all was replaced wholesale. The
|
|
251
|
+
file is now read whole and copied to `<name>.bak.json` before being touched.
|
|
252
|
+
|
|
253
|
+
- **The retry path erased the rate-limit block it had just walked into.**
|
|
254
|
+
`withGarminClient` called `resetGarminClient`, which clears the persisted
|
|
255
|
+
cooldown, and then forced a login into the live block — on the single path
|
|
256
|
+
that block exists to guard. Splitting "forget the session" from "forget the
|
|
257
|
+
limit" also lets the backoff ladder escalate, which it never could.
|
|
258
|
+
|
|
259
|
+
- **The cooldown test wrote a live five-minute Garmin block into the
|
|
260
|
+
developer's own database.** Found by attempting a backfill after the suite
|
|
261
|
+
passed. Tests no longer touch the real `app.db`.
|
|
262
|
+
|
|
263
|
+
### Notes
|
|
264
|
+
|
|
265
|
+
Two regression tests in this release passed against the deliberately broken
|
|
266
|
+
build before being rebuilt. Reverting the fix remains the only thing that
|
|
267
|
+
separates a regression test from a decoration.
|
|
268
|
+
|
|
269
|
+
## [0.4.0] — server 0.4.0 · watch 1.4.0 - 2026-09-03
|
|
270
|
+
|
|
271
|
+
Reported as "AI Unavailable, Error HTTP -400" on the Ask card. The AI was never
|
|
272
|
+
asked: the tunnel was down, ngrok answered an HTML error page, and Connect IQ
|
|
273
|
+
cannot parse HTML as JSON. Three surfaces had by now invented three vocabularies
|
|
274
|
+
for the same class of failure, and this release collapses them into one — then
|
|
275
|
+
adds the check that would have found it in a sentence.
|
|
276
|
+
|
|
277
|
+
### Fixed
|
|
278
|
+
|
|
279
|
+
- **A request that never reached the server was reported as a broken AI.**
|
|
280
|
+
`onPromptSubmitted` set the error to `"HTTP " + responseCode` under the heading
|
|
281
|
+
*AI unavailable*, so `-400` — which means the response body could not be parsed
|
|
282
|
+
— blamed the one component that had not been contacted. The pairing flow
|
|
283
|
+
already classified this exact code correctly and the prompt flow never called
|
|
284
|
+
it. `PairFail` is now `Fail` (`ciq/source/Fail.mc`) and pairing, the summary
|
|
285
|
+
fetch and the prompt all route through it.
|
|
286
|
+
|
|
287
|
+
- **A rotated API key was reported as an unreachable server.** Any summary
|
|
288
|
+
failure drew "Could not reach TrainBud", including a 401 from a server that had
|
|
289
|
+
answered perfectly well. A new `UNAUTHORIZED` class says *Watch not authorised
|
|
290
|
+
— pair this watch again*.
|
|
291
|
+
|
|
292
|
+
- **A prompt poll that failed every time looked like one still running.**
|
|
293
|
+
`onPromptStatusReceived` returned silently on any non-200, so the screen sat on
|
|
294
|
+
"Asking AI..." for thirty seconds and then said "Timed out", naming nothing.
|
|
295
|
+
One dropped Bluetooth response is now tolerated and a run of them is reported;
|
|
296
|
+
a 401 is reported at once.
|
|
297
|
+
|
|
298
|
+
- **No AI answer this app ever produced was readable.** The result was cut into
|
|
299
|
+
80-character substrings and each was handed to a single `drawText`, which does
|
|
300
|
+
not wrap in Monkey C — so a page was laid out on one line, ran off both edges,
|
|
301
|
+
and the cut fell mid-word. It survived every build, type check and store review
|
|
302
|
+
because no Anthropic key had ever been configured on the machine it was written
|
|
303
|
+
on, so the success path had never once been drawn. Answers now wrap to the
|
|
304
|
+
chord width and page by line.
|
|
305
|
+
|
|
306
|
+
- **`trainbud check` caused the failure it reported.** A rate-limited Garmin
|
|
307
|
+
login was retried once per tool call, so one expired session became nine logins
|
|
308
|
+
into a Cloudflare 429 in about five seconds. There is now a single cooldown,
|
|
309
|
+
taken from the upstream's own retry-after and honoured before any login is
|
|
310
|
+
attempted; a session already in hand keeps working.
|
|
311
|
+
|
|
312
|
+
- **A test asserted against the developer's environment.** `checkAiStatus`
|
|
313
|
+
cleared `ANTHROPIC_API_KEY` in a `before` hook, but `src/config.ts` calls dotenv
|
|
314
|
+
at module load, so the dynamic import that followed put it straight back. Green
|
|
315
|
+
for the life of the project purely because no key had ever existed on the
|
|
316
|
+
machine it ran on; it failed the day one was added.
|
|
317
|
+
|
|
318
|
+
### Fixed — found by an adversarial sweep
|
|
319
|
+
|
|
320
|
+
A ten-lens audit produced 52 candidates; each went to two independent verifiers
|
|
321
|
+
told to refute it, and 42 survived. The ones fixed here:
|
|
322
|
+
|
|
323
|
+
- **Every daily metric was fetched for the wrong day, west of UTC.**
|
|
324
|
+
garmin-connect derives the calendar day from the Date it is given by
|
|
325
|
+
subtracting `getTimezoneOffset()` — a function that round-trips a
|
|
326
|
+
local-midnight Date and shifts a UTC-midnight one. Every Date this codebase
|
|
327
|
+
produced was UTC midnight, so at UTC-5 the library asked Garmin about the 18th
|
|
328
|
+
while the answer was stored under the 19th. Sleep, resting heart rate, stress
|
|
329
|
+
and weight were all off by one day, silently, and every baseline and finding
|
|
330
|
+
built on them described a day the user did not live. Invisible here because
|
|
331
|
+
this machine is UTC+4. The suite now passes under Los Angeles, UTC, Baku and
|
|
332
|
+
Auckland.
|
|
333
|
+
|
|
334
|
+
- **Sleep debt was noise roughly half the time.** It summed one-sided shortfalls
|
|
335
|
+
against the user's own median, and half of anyone's nights fall below their
|
|
336
|
+
median by construction. Measured over 2000 synthetic steady sleepers with no
|
|
337
|
+
deficit at all it fired on 46.7% of weeks at an hour of night-to-night
|
|
338
|
+
variation. Now measured against a floor; false positives fall to 8.0% while a
|
|
339
|
+
genuine 1.5 h/night deficit is still caught 93% of the time.
|
|
340
|
+
|
|
341
|
+
- **An unworn night scored as a bad night.** Recovery read
|
|
342
|
+
`sleep?.sleepTimeSeconds ?? 0`, scored zero hours as 35/100, and dragged an
|
|
343
|
+
otherwise excellent day under the "fatigued" line.
|
|
344
|
+
|
|
345
|
+
- **A gap in the store read as a run of days.** The recent window was the last N
|
|
346
|
+
*points*, not N days.
|
|
347
|
+
|
|
348
|
+
- **The request handler could not survive a throw.** One unguarded async
|
|
349
|
+
callback, so a malformed `Host` header — accepted by Node's parser, rejected by
|
|
350
|
+
`new URL` — exited the process, with no credential required.
|
|
351
|
+
|
|
352
|
+
- **`trainbud setup` destroyed the Anthropic key**, and a Garmin password
|
|
353
|
+
containing `#` broke setup permanently, because values were written to `.env`
|
|
354
|
+
unquoted.
|
|
355
|
+
|
|
356
|
+
- **Every goal and injury was saved twice**; the dashboard bound its form twice.
|
|
357
|
+
|
|
358
|
+
- **`trainbud check` caused the rate limit it reported**, and a backfill answered
|
|
359
|
+
a 429 with ~1800 more requests.
|
|
360
|
+
|
|
361
|
+
- **`app.db` held the Anthropic key at 0644**, and live pairing codes were
|
|
362
|
+
written to the log.
|
|
363
|
+
|
|
364
|
+
- **The glance drew a tofu box** for the "h" in "6.3h"; **an expired pairing code
|
|
365
|
+
was polled forever**; **a rotated API key was hidden** behind ageing cached
|
|
366
|
+
numbers; and **the Ask card accepted presses with no AI key**.
|
|
367
|
+
|
|
368
|
+
- **The only type check CI ran never looked at a test file**, which is why four
|
|
369
|
+
stale fixtures compiled.
|
|
370
|
+
|
|
371
|
+
### Added
|
|
372
|
+
|
|
373
|
+
- **`trainbud doctor`, `GET /api/selftest`, and a Connection panel on the
|
|
374
|
+
dashboard.** Fetches the configured public URL from outside this machine with
|
|
375
|
+
the watch's own headers and grades the answer with the watch's taxonomy. Status
|
|
376
|
+
alone is not the test — both of this project's expensive network bugs were a
|
|
377
|
+
200 or a 404 carrying HTML — so the body is graded and a 200 of HTML fails.
|
|
378
|
+
|
|
379
|
+
- **A Week card, and `get_week_review`.** This week against last, per metric,
|
|
380
|
+
split by date rather than by position so a missing day cannot shift the
|
|
381
|
+
boundary. A metric absent on either side reads as unknown rather than as a
|
|
382
|
+
delta against zero.
|
|
383
|
+
|
|
384
|
+
- **A load forecast.** Where the acute:chronic ratio lands if next week repeats
|
|
385
|
+
this one. The existing detector fires after the jump; this fires before it. The
|
|
386
|
+
projection slides the whole 28-day window forward rather than only advancing
|
|
387
|
+
the acute end.
|
|
388
|
+
|
|
389
|
+
- **Sleep debt and consistency**, against the user's own median night rather than
|
|
390
|
+
eight hours, with consistency as median absolute deviation so one recovery
|
|
391
|
+
sleep cannot make a metronomic sleeper look erratic. Surfaced under last
|
|
392
|
+
night's hours as "Usually 7.2h · variable".
|
|
393
|
+
|
|
394
|
+
- **A race countdown.** The context store has held races since the memory layer
|
|
395
|
+
landed and nothing ever read one for its date, though it changes what every
|
|
396
|
+
other number means — a falling load ratio is a warning in January and the plan
|
|
397
|
+
in a taper. `activeContext` excludes future dates by design, so
|
|
398
|
+
`upcomingContext` was added.
|
|
399
|
+
|
|
400
|
+
- **`build.ps1 -Screens` and `scripts/capture-screens.ps1`.** Every screen the
|
|
401
|
+
app can draw, with no server, photographed on any device in one command. 1.3.0
|
|
402
|
+
shipped to the store having never been drawn once. Three faults it caught
|
|
403
|
+
immediately: the default jungle `sourcePath` was pulling the debug driver into
|
|
404
|
+
the store build; a jungle setting is assigned rather than appended, so listing
|
|
405
|
+
two jungles silently un-excluded the glance; and the Forerunner 55's recovery
|
|
406
|
+
card collided with its own ring.
|
|
407
|
+
|
|
408
|
+
## [watch 1.3.1] - 2026-09-02
|
|
409
|
+
|
|
410
|
+
Nobody who installed the watch app from the store could ever pair it. Reported
|
|
411
|
+
from a Forerunner 55 on firmware 11.03; it affected every user on every device.
|
|
412
|
+
|
|
413
|
+
### Fixed — watch
|
|
414
|
+
|
|
415
|
+
- **The store build shipped a default Server URL pointing at a developer's
|
|
416
|
+
personal ngrok tunnel.** From 1.2.0 onward `properties.xml` carried
|
|
417
|
+
`https://backpedal-immorally-cathouse.ngrok-free.dev` as the default, so a
|
|
418
|
+
fresh install found a non-empty URL, skipped the setup screen entirely, POSTed
|
|
419
|
+
`/api/pair` at a host that was usually offline, and rendered "Pairing failed"
|
|
420
|
+
forever. The tunnel answers `ERR_NGROK_3200` when it is not running; the watch
|
|
421
|
+
read the HTML error page as `-400` and reported a pairing failure. The default
|
|
422
|
+
is now empty, which routes a fresh install to a **Setup required** screen
|
|
423
|
+
naming the setup guide. Sideloading keeps a baked URL through the new
|
|
424
|
+
`ciq/monkey-dev.jungle` overlay, which is gitignored and never in a store
|
|
425
|
+
build.
|
|
426
|
+
|
|
427
|
+
The same default was a privacy hazard in the other direction: while that
|
|
428
|
+
tunnel was up, a stranger's watch minted a pairing code against the
|
|
429
|
+
developer's own health server, one approval click away from handing out a
|
|
430
|
+
bearer token to somebody else's Garmin data.
|
|
431
|
+
|
|
432
|
+
- **One "Pairing failed" screen became three, each naming what the user can
|
|
433
|
+
fix.** *Cannot reach server* (nothing answered), *Not a TrainBud server*
|
|
434
|
+
(something answered and it was not us — a dead tunnel, a captive portal, the
|
|
435
|
+
wrong address), *Server refused pairing*. The address in use is drawn on
|
|
436
|
+
screen whenever the address is the suspect, and `-104`, `-1001` and `429` get
|
|
437
|
+
their own one-line hints. `-1001` distinguishes a plain `http://` URL from an
|
|
438
|
+
`https://` one whose certificate was refused: the same code, two different
|
|
439
|
+
problems, and telling a self-hosted user to "use https" when they already do
|
|
440
|
+
is a dead end.
|
|
441
|
+
|
|
442
|
+
- **Round screens were detected by measuring pixels rather than asking the
|
|
443
|
+
device.** `isRoundScreen()` was `width == height && width >= 240`, so the
|
|
444
|
+
Forerunner 55 — round, 208×208 — was treated as rectangular and drew the
|
|
445
|
+
recovery bar instead of the ring. Now `System.getDeviceSettings().screenShape`.
|
|
446
|
+
|
|
447
|
+
- **Text wrapped to a fixed character count, which a circle does not honour.**
|
|
448
|
+
A line near the top of a round screen has far less room than one through the
|
|
449
|
+
middle; "Cannot reach server" rendered as "annot reach serve". Wrapping is now
|
|
450
|
+
measured in pixels against the chord width available at that height.
|
|
451
|
+
|
|
452
|
+
- **The five button-only products could not navigate.** `BehaviorDelegate` maps
|
|
453
|
+
UP and DOWN to `onNextPage`/`onPreviousPage` and neither was implemented, so
|
|
454
|
+
on fr55, fr745 and the three Instinct 3 variants the carousel only moved
|
|
455
|
+
forwards one START press at a time and the Ask menu could not be scrolled at
|
|
456
|
+
all. Every on-screen hint also said "tap"; hints are now chosen from
|
|
457
|
+
`System.getDeviceSettings().isTouchScreen`.
|
|
458
|
+
|
|
459
|
+
- **Grey was invisible on the Forerunner 55.** Its palette holds eight colours
|
|
460
|
+
and none of them is grey, so `COLOR_DK_GRAY` snapped to black on a black
|
|
461
|
+
background: inactive page dots, faded Ask items, card footnotes and the ring
|
|
462
|
+
track all vanished. Secondary elements use a colour that survives the palette,
|
|
463
|
+
and inactive dots are outlined rather than filled, so the hierarchy is carried
|
|
464
|
+
by shape.
|
|
465
|
+
|
|
466
|
+
- **Pairing telemetry is debug-only.** The pairing screen drew `9/8 200` in the
|
|
467
|
+
corner of a store build — unexplainable to a user, and the difference between
|
|
468
|
+
a poll that never ran and one discarded on the device to anyone debugging.
|
|
469
|
+
|
|
470
|
+
- **The AI disclaimer is now on screen.** The string existed from 1.2.0 and was
|
|
471
|
+
never drawn anywhere, so the app made training and recovery statements on a
|
|
472
|
+
health device with nothing to qualify them. It renders on the last page of an
|
|
473
|
+
answer.
|
|
474
|
+
|
|
475
|
+
### Fixed — watch, found by drawing it
|
|
476
|
+
|
|
477
|
+
Everything below was found by running 1.3.1 in the simulator on a Forerunner 55
|
|
478
|
+
and a fenix 8 47mm and looking at every card. None of it was visible from the
|
|
479
|
+
source, the type checker or a green build, and none of these screens had ever
|
|
480
|
+
been drawn on any watch: 1.3.0 shipped its `.iq` without a single render.
|
|
481
|
+
|
|
482
|
+
- **The Today card printed its first finding through its own title,** and cut a
|
|
483
|
+
character off each end of it: "Resting HR 4 bpm above" drew as "esting HR 4 bpm
|
|
484
|
+
above". It centred the text block on the screen rather than in the space below
|
|
485
|
+
the heading, and wrapped to a fixed 24 characters. This is card 0 — the first
|
|
486
|
+
thing a paired user sees.
|
|
487
|
+
|
|
488
|
+
- **The Sleep card drew an empty yellow box next to "6.3".** The FONT_NUMBER_*
|
|
489
|
+
faces contain digits and separators and no letters, so the "h" rendered as a
|
|
490
|
+
missing-glyph box — and a box has a width, so the "does this fit" check passed
|
|
491
|
+
and the value never stepped down to a font that has letters. The Recovery
|
|
492
|
+
card's "No data" had the same fault waiting.
|
|
493
|
+
|
|
494
|
+
- **The Ask AI card printed the selected prompt underneath its own hint:**
|
|
495
|
+
"Why is my re[STA]sting HR up?". The hint was right-justified against the
|
|
496
|
+
screen edge at the vertical centre, which on a circle is exactly where the
|
|
497
|
+
widest line already is.
|
|
498
|
+
|
|
499
|
+
- **The carousel dead-ended on the Ask card.** Going back from the first prompt
|
|
500
|
+
left the card, but going forward from the last one wrapped around, so the six
|
|
501
|
+
metric cards after it could only be reached by paging *backwards* from Today.
|
|
502
|
+
It now leaves at both ends, on buttons and on swipe.
|
|
503
|
+
|
|
504
|
+
- **The AI Insight card overlapped its own lines on large screens.** The line
|
|
505
|
+
step was a hardcoded 20 px, which is about right for the 208 px Forerunner 55
|
|
506
|
+
and much too small for a 454 px fenix. Every hardcoded line step is now
|
|
507
|
+
derived from the font.
|
|
508
|
+
|
|
509
|
+
- **The Recovery card printed "Ready" through the bottom of the score,** for the
|
|
510
|
+
same reason: the label sat at a fixed offset from the centre while the score
|
|
511
|
+
is drawn in a font whose height nearly doubles between those two devices. The
|
|
512
|
+
label and the heart rate line are now stacked off the score's measured height.
|
|
513
|
+
|
|
514
|
+
- **"Resting 48 Max 178" lost its last digits** at the bottom of a 208 px round
|
|
515
|
+
screen. It now shortens the *label* before the number: "Rest 48 Max 178".
|
|
516
|
+
|
|
517
|
+
- Activity card: the workout name was cut to 14 characters before being drawn,
|
|
518
|
+
which threw away room a smaller font would have used. `drawFittedValue`
|
|
519
|
+
already measures and steps down; the character cut is gone. This was the last
|
|
520
|
+
item open on that card.
|
|
521
|
+
|
|
522
|
+
### Fixed — "AI unavailable" meant four different things
|
|
523
|
+
|
|
524
|
+
Reported as "it still shows AI Unavailable". The cause on this install was that
|
|
525
|
+
**no Anthropic key had ever been configured**: the settings table was empty,
|
|
526
|
+
`.env` had no `ANTHROPIC_API_KEY`, and the only two prompt jobs ever created, on
|
|
527
|
+
2026-08-17 and 2026-08-19, both failed with
|
|
528
|
+
`ANTHROPIC_API_KEY not configured`. AI is bring-your-own-key and genuinely could
|
|
529
|
+
not run. The defect is that the watch could not say so.
|
|
530
|
+
|
|
531
|
+
- **`/api/watch` now carries `ai_configured`.** `ai_insight: null` meant three
|
|
532
|
+
different things — no key, a failed call, or today's insight not generated
|
|
533
|
+
yet — and the watch drew one screen for all of them. The Ask card now shows
|
|
534
|
+
**"AI not set up / Add an API key in the dashboard"** instead of offering five
|
|
535
|
+
questions that cannot succeed, and the AI Insight card no longer tells a user
|
|
536
|
+
with no key that there is "No insight today", which is a claim about today. A
|
|
537
|
+
summary from an older server carries no such field; missing is read as
|
|
538
|
+
configured, so nothing is asserted on no evidence.
|
|
539
|
+
|
|
540
|
+
- **The watch threw away the reason a prompt failed.** `GET /api/prompt/<id>`
|
|
541
|
+
returns the server's error string, and `onPromptStatusReceived` read only
|
|
542
|
+
`status` — so a missing API key and a provider outage rendered identically.
|
|
543
|
+
The reason is now drawn under the message, along with a timeout and the HTTP
|
|
544
|
+
code from a failed submit.
|
|
545
|
+
|
|
546
|
+
- **`trainbud check` reported AI status from the environment only.** It read
|
|
547
|
+
`appConfig.anthropicApiKey` instead of `isAiConfigured()`, so a key saved
|
|
548
|
+
through the dashboard — the route the setup guide tells users to take — was
|
|
549
|
+
reported as absent forever. `resolveAnthropicKey()` exists for exactly this
|
|
550
|
+
and this call site never used it.
|
|
551
|
+
|
|
552
|
+
### Fixed — dependencies
|
|
553
|
+
|
|
554
|
+
- `qs` 6.15.3 → 6.16.0 and `fast-uri` 3.1.5 → 3.1.7, clearing one high and one
|
|
555
|
+
moderate advisory. `npm audit` is clean.
|
|
556
|
+
|
|
557
|
+
### Removed
|
|
558
|
+
|
|
559
|
+
- Dead resources and code: `drawHint()` and the `TapHint` string it drew
|
|
560
|
+
(replaced by page dots in 1.2.0), plus the unused `AskHint`, `PairingPolling`
|
|
561
|
+
and `CardHeartRate` strings.
|
|
562
|
+
|
|
563
|
+
## [0.3.1] - 2026-08-19
|
|
564
|
+
|
|
565
|
+
Watch pairing works. It never had, on any build, and the cause was the last one
|
|
566
|
+
still open: a status poll that never reached the server.
|
|
567
|
+
|
|
568
|
+
### Fixed — watch
|
|
569
|
+
|
|
570
|
+
- **Pairing completes end to end.** ngrok's free tier answers any GET carrying a
|
|
571
|
+
browser-ish User-Agent with an HTML interstitial under a 200. Connect IQ sends
|
|
572
|
+
`Mozilla/5.0` and will not let an app override it, so every status poll was
|
|
573
|
+
answered by the tunnel, never reached the server, and failed on the watch as
|
|
574
|
+
`-400 INVALID_HTTP_BODY_IN_NETWORK_RESPONSE`. POSTs are not intercepted, which
|
|
575
|
+
is why `/api/pair` always worked and the poll never did. Verified in the
|
|
576
|
+
simulator against the live tunnel: code issued, approved in the dashboard,
|
|
577
|
+
credentials saved, summary fetched.
|
|
578
|
+
- Recovery ring drew the inverse of the score: the end angle is measured
|
|
579
|
+
clockwise but the arc was drawn counter-clockwise, so 91 rendered as the
|
|
580
|
+
missing 9%.
|
|
581
|
+
- Recovery ring overlapped the card title.
|
|
582
|
+
- Fractional values drew with six decimal places (`6.300000h`), on both the
|
|
583
|
+
widget and the glance.
|
|
584
|
+
- The four-cell overview grid used "No data" as its placeholder, which drew over
|
|
585
|
+
the neighbouring cells and their labels; it now uses a dash.
|
|
586
|
+
- Activity card: subtitle and footnote collided, and a strength workout listed
|
|
587
|
+
"0 km" as though zero were a measurement.
|
|
588
|
+
|
|
589
|
+
### Fixed — server
|
|
590
|
+
|
|
591
|
+
- **Recovery score was NaN on every default call** and reached the watch as
|
|
592
|
+
`null`: normalizeWeights spread an object of explicit `undefined` weights over
|
|
593
|
+
its defaults, and `NaN <= 0` is false so the guard missed it.
|
|
594
|
+
- **Stress and VO2 max were fetched from URLs Connect answers 404 for.** Both
|
|
595
|
+
take the date as a path segment; VO2 max is `maxmet/latest`, not
|
|
596
|
+
`maxmet/daily`. The stress mapper also read fields the response does not
|
|
597
|
+
contain (`overallStressLevel` rather than `avgStressLevel`), and Connect's
|
|
598
|
+
negative "not measured" sentinels were averaged in as real readings.
|
|
599
|
+
- Pair codes came from `Math.random()`. `/api/pair` is unauthenticated by
|
|
600
|
+
design, so an attacker could mint codes, recover the PRNG state, predict the
|
|
601
|
+
code the watch was showing and collect the API key on approval. Now
|
|
602
|
+
`crypto.randomInt`.
|
|
603
|
+
- Rate limiting only ever ran on `/mcp`, leaving the unauthenticated pair
|
|
604
|
+
endpoints open to a walk through the six-digit code space; and buckets were
|
|
605
|
+
keyed on the socket address, which behind a tunnel is one bucket for every
|
|
606
|
+
client. Limits now cover every route, with a tighter budget for pairing, keyed
|
|
607
|
+
on the forwarded address.
|
|
608
|
+
- API key compared in constant time.
|
|
609
|
+
- `.env`, `session.json` and the MCP client config were written world-readable
|
|
610
|
+
(0644) and are now 0600.
|
|
611
|
+
- A single failed Garmin login was memoised and poisoned every later call until
|
|
612
|
+
the process restarted.
|
|
613
|
+
- All four npm audit advisories cleared.
|
|
614
|
+
|
|
615
|
+
### Added
|
|
616
|
+
|
|
617
|
+
- Real Connect IQ Store screenshots, captured from a paired app in the
|
|
618
|
+
simulator (`ciq/store/screenshots/`). The previous set was drawn in PowerShell
|
|
619
|
+
and removed in 1.2.0.
|
|
620
|
+
- Tests: pair code randomness, pair rate limiting, secret file permissions,
|
|
621
|
+
client auth lifecycle, the real Connect stress payload, recovery weights.
|
|
622
|
+
91 tests, up from 73.
|
|
623
|
+
|
|
624
|
+
## [0.2.0] - 2026-06-26
|
|
625
|
+
|
|
626
|
+
### Fixed
|
|
627
|
+
|
|
628
|
+
- **Critical:** Logger no longer writes to stdout — pino-pretty uses stderr; file logging starts only when server starts
|
|
629
|
+
- **Critical:** Credentials validated at server startup, not on first tool call
|
|
630
|
+
- N+1 Garmin API calls batched with concurrency limit (`mapInBatches`, max 6 parallel)
|
|
631
|
+
- Body composition fetches parallelized instead of sequential
|
|
632
|
+
- Activities range queries use shared paginated pool cache (up to 500 activities) with truncation warning
|
|
633
|
+
- Cache keys unified via `buildToolCacheKey()` with stable sorted-param hashing
|
|
634
|
+
- Recovery tool uses yesterday's sleep data with fallback to prior nights
|
|
635
|
+
- Activity date filtering uses consistent Luxon parsing
|
|
636
|
+
- Session path resolved from single `getSessionPath()` in config
|
|
637
|
+
- Version read from `package.json` instead of hardcoded strings
|
|
638
|
+
- `GarminApiError` is now a proper class; auth retry uses `await`
|
|
639
|
+
- Tool errors sanitized before returning to MCP clients
|
|
640
|
+
- SQLite cache closed on SIGTERM/SIGINT/exit
|
|
641
|
+
|
|
642
|
+
### Added
|
|
643
|
+
|
|
644
|
+
- `src/version.ts`, `src/utils/batch.ts`, `src/garmin/garminApiTypes.ts`, `src/tools/types.ts`
|
|
645
|
+
- `filterActivitiesByRange()`, `sanitizeErrorMessage()`, `getYesterday()` helpers
|
|
646
|
+
- `configureLogger()` for lazy log file initialization
|
|
647
|
+
- `.nvmrc` (Node 20)
|
|
648
|
+
- `.github/workflows/publish.yml` for npm + GitHub Releases on version tags
|
|
649
|
+
- Project knowledge base moved to Obsidian vault (`05-Projects/trainbud/`); see `docs/VAULT.md`
|
|
650
|
+
- 11 new tests (32 total): cache key stability, date filtering, recovery scoring, error sanitization
|
|
651
|
+
|
|
652
|
+
### Changed
|
|
653
|
+
|
|
654
|
+
- **Rebranded** from garmin-mcp to **TrainBud** (package `trainbud`, CLI `trainbud`)
|
|
655
|
+
- README rewritten as product page with disclaimer, badges, and security section
|
|
656
|
+
- Added CONTRIBUTING.md; updated vault docs and examples
|
|
657
|
+
- Removed imports from internal `garmin-connect/dist/` paths
|
|
658
|
+
- Tool registry uses shared `ToolDefinition` interface without unsafe casts
|
|
659
|
+
- `runCacheClear` is synchronous
|
|
660
|
+
|
|
661
|
+
## [0.1.0] - 2026-06-26
|
|
662
|
+
|
|
663
|
+
### Added
|
|
664
|
+
|
|
665
|
+
- Initial MCP server exposing 6 Garmin Connect tools
|
|
666
|
+
- Email/password authentication with session persistence (`.trainbud/session.json`)
|
|
667
|
+
- SQLite caching layer with configurable TTL per resource type
|
|
668
|
+
- CLI commands: `start`, `auth`, `cache clear`, `status`
|
|
669
|
+
- Unit and integration tests using Node test runner
|
|
670
|
+
- README, QUICKSTART, and example prompts
|
|
671
|
+
|
|
672
|
+
### Notes
|
|
673
|
+
|
|
674
|
+
- Uses unofficial `garmin-connect` npm package (Windows/macOS/Linux compatible)
|
|
675
|
+
- MFA is not yet supported by the underlying library
|