trainbud 0.5.0 → 0.5.2

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.
Files changed (152) hide show
  1. package/.env.example +11 -11
  2. package/CHANGELOG.md +757 -675
  3. package/QUICKSTART.md +185 -182
  4. package/README.md +329 -322
  5. package/dist/appDb.d.ts +40 -1
  6. package/dist/appDb.js +120 -51
  7. package/dist/cli.js +47 -0
  8. package/dist/dashboard.js +820 -820
  9. package/dist/dashboardCharts.js +42 -42
  10. package/dist/deviceTokens.d.ts +29 -0
  11. package/dist/deviceTokens.js +49 -0
  12. package/dist/history/context.js +35 -35
  13. package/dist/history/schema.js +66 -66
  14. package/dist/httpServer.js +64 -1
  15. package/dist/pairApi.js +11 -2
  16. package/dist/promptApi.js +5 -5
  17. package/dist/usage.js +17 -17
  18. package/package.json +82 -77
  19. package/dist/appDb.d.ts.map +0 -1
  20. package/dist/appDb.js.map +0 -1
  21. package/dist/check.d.ts.map +0 -1
  22. package/dist/check.js.map +0 -1
  23. package/dist/cli.d.ts.map +0 -1
  24. package/dist/cli.js.map +0 -1
  25. package/dist/config.d.ts.map +0 -1
  26. package/dist/config.js.map +0 -1
  27. package/dist/dashboard.d.ts.map +0 -1
  28. package/dist/dashboard.js.map +0 -1
  29. package/dist/dashboardCharts.d.ts.map +0 -1
  30. package/dist/dashboardCharts.js.map +0 -1
  31. package/dist/dashboardData.d.ts.map +0 -1
  32. package/dist/dashboardData.js.map +0 -1
  33. package/dist/detect/baseline.d.ts.map +0 -1
  34. package/dist/detect/baseline.js.map +0 -1
  35. package/dist/detect/countdown.d.ts.map +0 -1
  36. package/dist/detect/countdown.js.map +0 -1
  37. package/dist/detect/detectors.d.ts.map +0 -1
  38. package/dist/detect/detectors.js.map +0 -1
  39. package/dist/detect/findings.d.ts.map +0 -1
  40. package/dist/detect/findings.js.map +0 -1
  41. package/dist/detect/forecast.d.ts.map +0 -1
  42. package/dist/detect/forecast.js.map +0 -1
  43. package/dist/detect/index.d.ts.map +0 -1
  44. package/dist/detect/index.js.map +0 -1
  45. package/dist/detect/sleepQuality.d.ts.map +0 -1
  46. package/dist/detect/sleepQuality.js.map +0 -1
  47. package/dist/detect/trimp.d.ts.map +0 -1
  48. package/dist/detect/trimp.js.map +0 -1
  49. package/dist/detect/week.d.ts.map +0 -1
  50. package/dist/detect/week.js.map +0 -1
  51. package/dist/garmin/auth.d.ts.map +0 -1
  52. package/dist/garmin/auth.js.map +0 -1
  53. package/dist/garmin/cache.d.ts.map +0 -1
  54. package/dist/garmin/cache.js.map +0 -1
  55. package/dist/garmin/client.d.ts.map +0 -1
  56. package/dist/garmin/client.js.map +0 -1
  57. package/dist/garmin/daily.d.ts.map +0 -1
  58. package/dist/garmin/daily.js.map +0 -1
  59. package/dist/garmin/garminApiTypes.d.ts.map +0 -1
  60. package/dist/garmin/garminApiTypes.js.map +0 -1
  61. package/dist/garmin/garminConnect.d.ts.map +0 -1
  62. package/dist/garmin/garminConnect.js.map +0 -1
  63. package/dist/garmin/partial.d.ts.map +0 -1
  64. package/dist/garmin/partial.js.map +0 -1
  65. package/dist/garmin/rawApi.d.ts.map +0 -1
  66. package/dist/garmin/rawApi.js.map +0 -1
  67. package/dist/garmin/types.d.ts.map +0 -1
  68. package/dist/garmin/types.js.map +0 -1
  69. package/dist/history/capture.d.ts.map +0 -1
  70. package/dist/history/capture.js.map +0 -1
  71. package/dist/history/context.d.ts.map +0 -1
  72. package/dist/history/context.js.map +0 -1
  73. package/dist/history/fallback.d.ts.map +0 -1
  74. package/dist/history/fallback.js.map +0 -1
  75. package/dist/history/ingest.d.ts.map +0 -1
  76. package/dist/history/ingest.js.map +0 -1
  77. package/dist/history/scheduler.d.ts.map +0 -1
  78. package/dist/history/scheduler.js.map +0 -1
  79. package/dist/history/schema.d.ts.map +0 -1
  80. package/dist/history/schema.js.map +0 -1
  81. package/dist/history/store.d.ts.map +0 -1
  82. package/dist/history/store.js.map +0 -1
  83. package/dist/httpServer.d.ts.map +0 -1
  84. package/dist/httpServer.js.map +0 -1
  85. package/dist/index.d.ts.map +0 -1
  86. package/dist/index.js.map +0 -1
  87. package/dist/mcpConfig.d.ts.map +0 -1
  88. package/dist/mcpConfig.js.map +0 -1
  89. package/dist/pairApi.d.ts.map +0 -1
  90. package/dist/pairApi.js.map +0 -1
  91. package/dist/paths.d.ts.map +0 -1
  92. package/dist/paths.js.map +0 -1
  93. package/dist/profile.d.ts.map +0 -1
  94. package/dist/profile.js.map +0 -1
  95. package/dist/promptApi.d.ts.map +0 -1
  96. package/dist/promptApi.js.map +0 -1
  97. package/dist/promptSuggestions.d.ts.map +0 -1
  98. package/dist/promptSuggestions.js.map +0 -1
  99. package/dist/selfTest.d.ts.map +0 -1
  100. package/dist/selfTest.js.map +0 -1
  101. package/dist/server.d.ts.map +0 -1
  102. package/dist/server.js.map +0 -1
  103. package/dist/setup.d.ts.map +0 -1
  104. package/dist/setup.js.map +0 -1
  105. package/dist/toolErrors.d.ts.map +0 -1
  106. package/dist/toolErrors.js.map +0 -1
  107. package/dist/tools/activities.d.ts.map +0 -1
  108. package/dist/tools/activities.js.map +0 -1
  109. package/dist/tools/bodyComposition.d.ts.map +0 -1
  110. package/dist/tools/bodyComposition.js.map +0 -1
  111. package/dist/tools/context.d.ts.map +0 -1
  112. package/dist/tools/context.js.map +0 -1
  113. package/dist/tools/findings.d.ts.map +0 -1
  114. package/dist/tools/findings.js.map +0 -1
  115. package/dist/tools/heartRate.d.ts.map +0 -1
  116. package/dist/tools/heartRate.js.map +0 -1
  117. package/dist/tools/index.d.ts.map +0 -1
  118. package/dist/tools/index.js.map +0 -1
  119. package/dist/tools/payloads.d.ts.map +0 -1
  120. package/dist/tools/payloads.js.map +0 -1
  121. package/dist/tools/recovery.d.ts.map +0 -1
  122. package/dist/tools/recovery.js.map +0 -1
  123. package/dist/tools/sleep.d.ts.map +0 -1
  124. package/dist/tools/sleep.js.map +0 -1
  125. package/dist/tools/stress.d.ts.map +0 -1
  126. package/dist/tools/stress.js.map +0 -1
  127. package/dist/tools/trainingInsights.d.ts.map +0 -1
  128. package/dist/tools/trainingInsights.js.map +0 -1
  129. package/dist/tools/types.d.ts.map +0 -1
  130. package/dist/tools/types.js.map +0 -1
  131. package/dist/tools/vo2Max.d.ts.map +0 -1
  132. package/dist/tools/vo2Max.js.map +0 -1
  133. package/dist/tools/week.d.ts.map +0 -1
  134. package/dist/tools/week.js.map +0 -1
  135. package/dist/usage.d.ts.map +0 -1
  136. package/dist/usage.js.map +0 -1
  137. package/dist/utils/batch.d.ts.map +0 -1
  138. package/dist/utils/batch.js.map +0 -1
  139. package/dist/utils/helpers.d.ts.map +0 -1
  140. package/dist/utils/helpers.js.map +0 -1
  141. package/dist/utils/jsonFile.d.ts.map +0 -1
  142. package/dist/utils/jsonFile.js.map +0 -1
  143. package/dist/utils/logger.d.ts.map +0 -1
  144. package/dist/utils/logger.js.map +0 -1
  145. package/dist/utils/secretFile.d.ts.map +0 -1
  146. package/dist/utils/secretFile.js.map +0 -1
  147. package/dist/utils/stdio.d.ts.map +0 -1
  148. package/dist/utils/stdio.js.map +0 -1
  149. package/dist/version.d.ts.map +0 -1
  150. package/dist/version.js.map +0 -1
  151. package/dist/watchApi.d.ts.map +0 -1
  152. package/dist/watchApi.js.map +0 -1
package/dist/dashboard.js CHANGED
@@ -67,13 +67,13 @@ function renderTile(tile) {
67
67
  : tile.state === "hard"
68
68
  ? "off baseline"
69
69
  : "";
70
- return `<div class="tile">
71
- <div class="tile-label">${escapeHtml(tile.label)}</div>
72
- <div class="tile-value">${escapeHtml(tile.value)}</div>
73
- ${tile.note ? `<div class="tile-note">${escapeHtml(tile.note)}</div>` : `<div class="tile-note">&nbsp;</div>`}
70
+ return `<div class="tile">
71
+ <div class="tile-label">${escapeHtml(tile.label)}</div>
72
+ <div class="tile-value">${escapeHtml(tile.value)}</div>
73
+ ${tile.note ? `<div class="tile-note">${escapeHtml(tile.note)}</div>` : `<div class="tile-note">&nbsp;</div>`}
74
74
  ${stateWord
75
75
  ? `<div class="tile-state"><span class="dot dot-${tile.state}"></span>${stateWord}</div>`
76
- : `<div class="tile-state muted-dim">not graded</div>`}
76
+ : `<div class="tile-state muted-dim">not graded</div>`}
77
77
  </div>`;
78
78
  }
79
79
  function severityWord(severity) {
@@ -87,27 +87,27 @@ function renderToday(data) {
87
87
  return `<p class="note">${escapeHtml(data.coverageNote)}</p>`;
88
88
  }
89
89
  if (data.findings.length === 0) {
90
- return `<p class="hero-line">Nothing stands out today.</p>
90
+ return `<p class="hero-line">Nothing stands out today.</p>
91
91
  <p class="muted">Measured against your own baselines over ${data.coverageDays} days.</p>`;
92
92
  }
93
93
  return data.findings
94
- .map((finding) => `<div class="finding">
95
- <div class="finding-head"><span class="dot dot-${severityWord(finding.severity)}"></span>${escapeHtml(finding.headline)}</div>
96
- <p class="muted">${escapeHtml(finding.detail)}</p>
94
+ .map((finding) => `<div class="finding">
95
+ <div class="finding-head"><span class="dot dot-${severityWord(finding.severity)}"></span>${escapeHtml(finding.headline)}</div>
96
+ <p class="muted">${escapeHtml(finding.detail)}</p>
97
97
  </div>`)
98
98
  .join("");
99
99
  }
100
100
  function renderCardRows(order, hidden) {
101
101
  return order
102
- .map((id, index) => `<li class="card-row" data-card="${escapeHtml(id)}">
103
- <label class="card-toggle">
104
- <input type="checkbox" data-card-visible="${escapeHtml(id)}" ${hidden.includes(id) ? "" : "checked"}>
105
- <span>${escapeHtml(CARD_LABELS[id] ?? id)}</span>
106
- </label>
107
- <span class="card-moves">
108
- <button type="button" class="btn-icon" data-move="up" data-card-move="${escapeHtml(id)}" ${index === 0 ? "disabled" : ""} aria-label="Move up">&uarr;</button>
109
- <button type="button" class="btn-icon" data-move="down" data-card-move="${escapeHtml(id)}" ${index === order.length - 1 ? "disabled" : ""} aria-label="Move down">&darr;</button>
110
- </span>
102
+ .map((id, index) => `<li class="card-row" data-card="${escapeHtml(id)}">
103
+ <label class="card-toggle">
104
+ <input type="checkbox" data-card-visible="${escapeHtml(id)}" ${hidden.includes(id) ? "" : "checked"}>
105
+ <span>${escapeHtml(CARD_LABELS[id] ?? id)}</span>
106
+ </label>
107
+ <span class="card-moves">
108
+ <button type="button" class="btn-icon" data-move="up" data-card-move="${escapeHtml(id)}" ${index === 0 ? "disabled" : ""} aria-label="Move up">&uarr;</button>
109
+ <button type="button" class="btn-icon" data-move="down" data-card-move="${escapeHtml(id)}" ${index === order.length - 1 ? "disabled" : ""} aria-label="Move down">&darr;</button>
110
+ </span>
111
111
  </li>`)
112
112
  .join("");
113
113
  }
@@ -123,16 +123,16 @@ function renderCardRows(order, hidden) {
123
123
  * load, on add and on remove, and one owner of that rule is enough.
124
124
  */
125
125
  function promptRow(text) {
126
- return `<li class="card-row prompt-row">
127
- <input type="text" class="prompt-text" maxlength="${PROMPT_MAX_LENGTH}"
128
- value="${escapeHtml(text)}" placeholder="A question in your own words"
129
- aria-label="Ask question">
130
- <span class="card-moves">
131
- <span class="prompt-count muted">0/${PROMPT_MAX_LENGTH}</span>
132
- <button type="button" class="btn-icon" data-move="up" data-prompt-move="up" aria-label="Move up">&uarr;</button>
133
- <button type="button" class="btn-icon" data-move="down" data-prompt-move="down" aria-label="Move down">&darr;</button>
134
- <button type="button" class="btn-icon" data-prompt-remove aria-label="Remove">&times;</button>
135
- </span>
126
+ return `<li class="card-row prompt-row">
127
+ <input type="text" class="prompt-text" maxlength="${PROMPT_MAX_LENGTH}"
128
+ value="${escapeHtml(text)}" placeholder="A question in your own words"
129
+ aria-label="Ask question">
130
+ <span class="card-moves">
131
+ <span class="prompt-count muted">0/${PROMPT_MAX_LENGTH}</span>
132
+ <button type="button" class="btn-icon" data-move="up" data-prompt-move="up" aria-label="Move up">&uarr;</button>
133
+ <button type="button" class="btn-icon" data-move="down" data-prompt-move="down" aria-label="Move down">&darr;</button>
134
+ <button type="button" class="btn-icon" data-prompt-remove aria-label="Remove">&times;</button>
135
+ </span>
136
136
  </li>`;
137
137
  }
138
138
  function renderPromptRows(prompts) {
@@ -164,840 +164,840 @@ export function renderDashboard(publicUrl) {
164
164
  format: (value) => value.toFixed(1),
165
165
  });
166
166
  const spendChart = columnChart(data.usage.daily.map((day) => ({ label: day.day, value: day.costUsd })), { label: "AI spend, 30 days", format: money });
167
- return `<!DOCTYPE html>
168
- <html lang="en">
169
- <head>
170
- <meta charset="UTF-8">
171
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
172
- <meta name="color-scheme" content="dark">
173
- <meta name="referrer" content="no-referrer">
174
- <title>TrainBud</title>
175
- <style>
176
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
177
- :root {
178
- --ground: ${PALETTE.ground};
179
- --surface: ${PALETTE.surface};
180
- --raised: ${PALETTE.raised};
181
- --line: ${PALETTE.line};
182
- --ink: ${PALETTE.ink};
183
- --muted: ${PALETTE.muted};
184
- --series: ${PALETTE.series};
185
- --good: ${PALETTE.good};
186
- --caution: ${PALETTE.caution};
187
- --hard: ${PALETTE.hard};
188
- }
189
- html { -webkit-text-size-adjust: 100%; }
190
- body {
191
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
192
- background: var(--ground); color: var(--ink);
193
- line-height: 1.5; font-size: 15px;
194
- padding: 16px 16px 56px;
195
- }
196
- .wrap { max-width: 640px; margin: 0 auto; }
197
-
198
- header { padding: 4px 0 20px; }
199
- h1 { font-size: 1.15rem; font-weight: 650; letter-spacing: -0.01em; }
200
- .sub { color: var(--muted); font-size: 0.8rem; margin-top: 2px; overflow-wrap: anywhere; }
201
-
202
- section { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
203
- padding: 16px; margin-bottom: 14px; }
204
- h2 { font-size: 0.72rem; font-weight: 600; color: var(--muted);
205
- text-transform: uppercase; letter-spacing: 0.09em; margin-bottom: 12px; }
206
- h3 { font-size: 0.85rem; font-weight: 600; margin: 16px 0 6px; }
207
- .muted { color: var(--muted); font-size: 0.85rem; }
208
- .muted-dim { color: var(--muted); opacity: 0.6; font-size: 0.75rem; }
209
-
210
- /* The one hero line on the page. */
211
- .hero-line { font-size: 1.05rem; font-weight: 550; }
212
- .note { color: var(--ink); font-size: 0.9rem; background: var(--raised);
213
- border-left: 2px solid var(--caution); border-radius: 0 8px 8px 0; padding: 10px 12px; }
214
-
215
- .finding { padding: 10px 0; border-bottom: 1px solid var(--line); }
216
- .finding:last-child { border-bottom: none; padding-bottom: 0; }
217
- .finding-head { font-weight: 550; font-size: 0.95rem; display: flex; gap: 8px; align-items: baseline; }
218
-
219
- /* Status is a dot plus a word. Never colour alone, never on text. */
220
- .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
221
- .dot-good { background: var(--good); }
222
- .dot-caution { background: var(--caution); }
223
- .dot-hard { background: var(--hard); }
224
- .dot-unknown { background: var(--muted); }
225
-
226
- .tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
227
- @media (min-width: 520px) { .tiles { grid-template-columns: repeat(4, 1fr); } }
228
- .tile { background: var(--raised); border-radius: 10px; padding: 12px; }
229
- .tile-label { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; }
230
- .tile-value { font-size: 1.5rem; font-weight: 600; margin-top: 4px; letter-spacing: -0.02em; }
231
- .tile-note { color: var(--muted); font-size: 0.75rem; }
232
- .tile-state { font-size: 0.72rem; color: var(--muted); margin-top: 6px;
233
- display: flex; align-items: center; gap: 6px; }
234
-
235
- /* Capped on purpose. An SVG with width:100% scales its own text with it,
236
- so on a wide screen a 320-unit viewBox doubled and the chart labels came
237
- out twice the size of the body copy. The cap keeps every chart between
238
- roughly 0.85x and 1.2x of its natural size, which is the range where the
239
- 11px labels stay 11px-ish at both ends. */
240
- .chart { width: 100%; max-width: 420px; height: auto; display: block; margin-top: 6px; }
241
- .chart .bar, .chart .dot { transition: opacity .12s; }
242
- .chart:hover .bar, .chart:hover .dot { opacity: .55; }
243
- .chart .bar:hover, .chart .dot:hover { opacity: 1; }
244
-
245
- .row { display: flex; align-items: center; justify-content: space-between;
246
- gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); }
247
- .row:last-child { border-bottom: none; }
248
- .row .label { color: var(--muted); font-size: 0.85rem; }
249
- .ok { color: var(--good); font-size: 0.85rem; }
250
- .warn { color: var(--caution); font-size: 0.85rem; }
251
- .err { color: var(--hard); font-size: 0.85rem; }
252
-
253
- button { font: inherit; border: none; border-radius: 8px; padding: 9px 16px;
254
- cursor: pointer; color: var(--ink); background: var(--raised); }
255
- button:hover { background: #24304f; }
256
- button:disabled { opacity: .35; cursor: default; }
257
- button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--series); outline-offset: 1px; }
258
- .btn-primary { background: var(--series); color: #fff; font-weight: 550; }
259
- .btn-primary:hover { background: #2f6fc4; }
260
- .btn-icon { padding: 4px 9px; font-size: 0.85rem; }
261
-
262
- label.field { display: block; margin-bottom: 10px; }
263
- label.field > span { display: block; color: var(--muted); font-size: 0.78rem; margin-bottom: 4px; }
264
- input, select { font: inherit; width: 100%; background: var(--ground); color: var(--ink);
265
- border: 1px solid var(--line); border-radius: 8px; padding: 9px 11px; }
266
- input[type=checkbox] { width: auto; }
267
- .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
268
- .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
269
-
270
- details { border-top: 1px solid var(--line); }
271
- details > summary { cursor: pointer; padding: 13px 0; font-size: 0.9rem; font-weight: 550;
272
- list-style: none; display: flex; justify-content: space-between; align-items: center; }
273
- details > summary::-webkit-details-marker { display: none; }
274
- details > summary::after { content: "+"; color: var(--muted); font-size: 1.1rem; }
275
- details[open] > summary::after { content: "\\2212"; }
276
- details > div.body { padding-bottom: 16px; }
277
-
278
- .pair-card { display: flex; align-items: center; gap: 14px; padding: 12px;
279
- background: var(--raised); border-radius: 10px; margin-bottom: 8px; flex-wrap: wrap; }
280
- .code { font-size: 1.8rem; font-weight: 700; letter-spacing: 0.22em; font-variant-numeric: tabular-nums; }
281
-
282
- ul.cards { list-style: none; }
283
- .card-row { display: flex; align-items: center; justify-content: space-between;
284
- padding: 7px 0; border-bottom: 1px solid var(--line); }
285
- .card-row:last-child { border-bottom: none; }
286
- .card-toggle { display: flex; align-items: center; gap: 10px; font-size: 0.9rem; }
287
- .card-moves { display: flex; gap: 6px; align-items: center; }
288
- .prompt-row { gap: 10px; }
289
- .prompt-row input { flex: 1 1 auto; min-width: 0; }
290
- /* Tabular figures so the count does not jitter the buttons sideways as it
291
- ticks from 9 to 10. */
292
- .prompt-count { font-size: 0.75rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
293
- .prompt-count.full { color: var(--caution); }
294
-
295
- pre { background: var(--ground); border: 1px solid var(--line); border-radius: 8px;
296
- padding: 11px; overflow-x: auto; font-size: 0.78rem; color: var(--muted); }
297
- code { color: #7CC4FF; font-family: ui-monospace, "SF Mono", Menlo, monospace; overflow-wrap: anywhere; }
298
- table { width: 100%; border-collapse: collapse; font-size: 0.82rem; font-variant-numeric: tabular-nums; }
299
- th { text-align: left; color: var(--muted); font-weight: 500; font-size: 0.72rem;
300
- text-transform: uppercase; letter-spacing: 0.06em; padding: 6px 0; }
301
- td { padding: 6px 0; border-top: 1px solid var(--line); }
302
- td.num { text-align: right; }
303
-
304
- .toast { position: fixed; left: 50%; transform: translateX(-50%); bottom: 20px;
305
- background: var(--raised); border: 1px solid var(--line); color: var(--ink);
306
- padding: 11px 20px; border-radius: 10px; font-size: 0.88rem;
307
- opacity: 0; transition: opacity .2s; pointer-events: none; max-width: 90vw; }
308
- .toast.show { opacity: 1; }
309
- .toast.err { border-color: var(--hard); }
310
- @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
311
- </style>
312
- </head>
313
- <body>
314
- <div class="wrap">
315
- <header>
316
- <h1>${profile.displayName ? escapeHtml(profile.displayName) : "TrainBud"}</h1>
317
- <p class="sub"><code>${escapeHtml(serverUrl)}</code></p>
318
- </header>
319
-
320
- <section>
321
- <h2>Today</h2>
322
- ${renderToday(data)}
167
+ return `<!DOCTYPE html>
168
+ <html lang="en">
169
+ <head>
170
+ <meta charset="UTF-8">
171
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
172
+ <meta name="color-scheme" content="dark">
173
+ <meta name="referrer" content="no-referrer">
174
+ <title>TrainBud</title>
175
+ <style>
176
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
177
+ :root {
178
+ --ground: ${PALETTE.ground};
179
+ --surface: ${PALETTE.surface};
180
+ --raised: ${PALETTE.raised};
181
+ --line: ${PALETTE.line};
182
+ --ink: ${PALETTE.ink};
183
+ --muted: ${PALETTE.muted};
184
+ --series: ${PALETTE.series};
185
+ --good: ${PALETTE.good};
186
+ --caution: ${PALETTE.caution};
187
+ --hard: ${PALETTE.hard};
188
+ }
189
+ html { -webkit-text-size-adjust: 100%; }
190
+ body {
191
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
192
+ background: var(--ground); color: var(--ink);
193
+ line-height: 1.5; font-size: 15px;
194
+ padding: 16px 16px 56px;
195
+ }
196
+ .wrap { max-width: 640px; margin: 0 auto; }
197
+
198
+ header { padding: 4px 0 20px; }
199
+ h1 { font-size: 1.15rem; font-weight: 650; letter-spacing: -0.01em; }
200
+ .sub { color: var(--muted); font-size: 0.8rem; margin-top: 2px; overflow-wrap: anywhere; }
201
+
202
+ section { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
203
+ padding: 16px; margin-bottom: 14px; }
204
+ h2 { font-size: 0.72rem; font-weight: 600; color: var(--muted);
205
+ text-transform: uppercase; letter-spacing: 0.09em; margin-bottom: 12px; }
206
+ h3 { font-size: 0.85rem; font-weight: 600; margin: 16px 0 6px; }
207
+ .muted { color: var(--muted); font-size: 0.85rem; }
208
+ .muted-dim { color: var(--muted); opacity: 0.6; font-size: 0.75rem; }
209
+
210
+ /* The one hero line on the page. */
211
+ .hero-line { font-size: 1.05rem; font-weight: 550; }
212
+ .note { color: var(--ink); font-size: 0.9rem; background: var(--raised);
213
+ border-left: 2px solid var(--caution); border-radius: 0 8px 8px 0; padding: 10px 12px; }
214
+
215
+ .finding { padding: 10px 0; border-bottom: 1px solid var(--line); }
216
+ .finding:last-child { border-bottom: none; padding-bottom: 0; }
217
+ .finding-head { font-weight: 550; font-size: 0.95rem; display: flex; gap: 8px; align-items: baseline; }
218
+
219
+ /* Status is a dot plus a word. Never colour alone, never on text. */
220
+ .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
221
+ .dot-good { background: var(--good); }
222
+ .dot-caution { background: var(--caution); }
223
+ .dot-hard { background: var(--hard); }
224
+ .dot-unknown { background: var(--muted); }
225
+
226
+ .tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
227
+ @media (min-width: 520px) { .tiles { grid-template-columns: repeat(4, 1fr); } }
228
+ .tile { background: var(--raised); border-radius: 10px; padding: 12px; }
229
+ .tile-label { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; }
230
+ .tile-value { font-size: 1.5rem; font-weight: 600; margin-top: 4px; letter-spacing: -0.02em; }
231
+ .tile-note { color: var(--muted); font-size: 0.75rem; }
232
+ .tile-state { font-size: 0.72rem; color: var(--muted); margin-top: 6px;
233
+ display: flex; align-items: center; gap: 6px; }
234
+
235
+ /* Capped on purpose. An SVG with width:100% scales its own text with it,
236
+ so on a wide screen a 320-unit viewBox doubled and the chart labels came
237
+ out twice the size of the body copy. The cap keeps every chart between
238
+ roughly 0.85x and 1.2x of its natural size, which is the range where the
239
+ 11px labels stay 11px-ish at both ends. */
240
+ .chart { width: 100%; max-width: 420px; height: auto; display: block; margin-top: 6px; }
241
+ .chart .bar, .chart .dot { transition: opacity .12s; }
242
+ .chart:hover .bar, .chart:hover .dot { opacity: .55; }
243
+ .chart .bar:hover, .chart .dot:hover { opacity: 1; }
244
+
245
+ .row { display: flex; align-items: center; justify-content: space-between;
246
+ gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); }
247
+ .row:last-child { border-bottom: none; }
248
+ .row .label { color: var(--muted); font-size: 0.85rem; }
249
+ .ok { color: var(--good); font-size: 0.85rem; }
250
+ .warn { color: var(--caution); font-size: 0.85rem; }
251
+ .err { color: var(--hard); font-size: 0.85rem; }
252
+
253
+ button { font: inherit; border: none; border-radius: 8px; padding: 9px 16px;
254
+ cursor: pointer; color: var(--ink); background: var(--raised); }
255
+ button:hover { background: #24304f; }
256
+ button:disabled { opacity: .35; cursor: default; }
257
+ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--series); outline-offset: 1px; }
258
+ .btn-primary { background: var(--series); color: #fff; font-weight: 550; }
259
+ .btn-primary:hover { background: #2f6fc4; }
260
+ .btn-icon { padding: 4px 9px; font-size: 0.85rem; }
261
+
262
+ label.field { display: block; margin-bottom: 10px; }
263
+ label.field > span { display: block; color: var(--muted); font-size: 0.78rem; margin-bottom: 4px; }
264
+ input, select { font: inherit; width: 100%; background: var(--ground); color: var(--ink);
265
+ border: 1px solid var(--line); border-radius: 8px; padding: 9px 11px; }
266
+ input[type=checkbox] { width: auto; }
267
+ .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
268
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
269
+
270
+ details { border-top: 1px solid var(--line); }
271
+ details > summary { cursor: pointer; padding: 13px 0; font-size: 0.9rem; font-weight: 550;
272
+ list-style: none; display: flex; justify-content: space-between; align-items: center; }
273
+ details > summary::-webkit-details-marker { display: none; }
274
+ details > summary::after { content: "+"; color: var(--muted); font-size: 1.1rem; }
275
+ details[open] > summary::after { content: "\\2212"; }
276
+ details > div.body { padding-bottom: 16px; }
277
+
278
+ .pair-card { display: flex; align-items: center; gap: 14px; padding: 12px;
279
+ background: var(--raised); border-radius: 10px; margin-bottom: 8px; flex-wrap: wrap; }
280
+ .code { font-size: 1.8rem; font-weight: 700; letter-spacing: 0.22em; font-variant-numeric: tabular-nums; }
281
+
282
+ ul.cards { list-style: none; }
283
+ .card-row { display: flex; align-items: center; justify-content: space-between;
284
+ padding: 7px 0; border-bottom: 1px solid var(--line); }
285
+ .card-row:last-child { border-bottom: none; }
286
+ .card-toggle { display: flex; align-items: center; gap: 10px; font-size: 0.9rem; }
287
+ .card-moves { display: flex; gap: 6px; align-items: center; }
288
+ .prompt-row { gap: 10px; }
289
+ .prompt-row input { flex: 1 1 auto; min-width: 0; }
290
+ /* Tabular figures so the count does not jitter the buttons sideways as it
291
+ ticks from 9 to 10. */
292
+ .prompt-count { font-size: 0.75rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
293
+ .prompt-count.full { color: var(--caution); }
294
+
295
+ pre { background: var(--ground); border: 1px solid var(--line); border-radius: 8px;
296
+ padding: 11px; overflow-x: auto; font-size: 0.78rem; color: var(--muted); }
297
+ code { color: #7CC4FF; font-family: ui-monospace, "SF Mono", Menlo, monospace; overflow-wrap: anywhere; }
298
+ table { width: 100%; border-collapse: collapse; font-size: 0.82rem; font-variant-numeric: tabular-nums; }
299
+ th { text-align: left; color: var(--muted); font-weight: 500; font-size: 0.72rem;
300
+ text-transform: uppercase; letter-spacing: 0.06em; padding: 6px 0; }
301
+ td { padding: 6px 0; border-top: 1px solid var(--line); }
302
+ td.num { text-align: right; }
303
+
304
+ .toast { position: fixed; left: 50%; transform: translateX(-50%); bottom: 20px;
305
+ background: var(--raised); border: 1px solid var(--line); color: var(--ink);
306
+ padding: 11px 20px; border-radius: 10px; font-size: 0.88rem;
307
+ opacity: 0; transition: opacity .2s; pointer-events: none; max-width: 90vw; }
308
+ .toast.show { opacity: 1; }
309
+ .toast.err { border-color: var(--hard); }
310
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
311
+ </style>
312
+ </head>
313
+ <body>
314
+ <div class="wrap">
315
+ <header>
316
+ <h1>${profile.displayName ? escapeHtml(profile.displayName) : "TrainBud"}</h1>
317
+ <p class="sub"><code>${escapeHtml(serverUrl)}</code></p>
318
+ </header>
319
+
320
+ <section>
321
+ <h2>Today</h2>
322
+ ${renderToday(data)}
323
323
  ${data.race
324
324
  ? `<p class="muted" style="margin-top:10px">${escapeHtml(data.race.text)} — ${data.race.daysAway} day${data.race.daysAway === 1 ? "" : "s"} away (${escapeHtml(data.race.phase.replace(/_/g, " "))}).</p>`
325
- : ""}
326
- <div class="tiles" style="margin-top:14px">
327
- ${data.tiles.map(renderTile).join("")}
328
- </div>
329
- ${data.insight ? `<p class="muted" style="margin-top:14px"><strong style="color:var(--ink);font-weight:550">Today's insight.</strong> ${escapeHtml(data.insight)}</p>` : ""}
330
- </section>
331
-
332
- <section>
333
- <h2>This week</h2>
334
- <p class="muted">${escapeHtml(data.week.headline)}</p>
335
- ${weekChart}
325
+ : ""}
326
+ <div class="tiles" style="margin-top:14px">
327
+ ${data.tiles.map(renderTile).join("")}
328
+ </div>
329
+ ${data.insight ? `<p class="muted" style="margin-top:14px"><strong style="color:var(--ink);font-weight:550">Today's insight.</strong> ${escapeHtml(data.insight)}</p>` : ""}
330
+ </section>
331
+
332
+ <section>
333
+ <h2>This week</h2>
334
+ <p class="muted">${escapeHtml(data.week.headline)}</p>
335
+ ${weekChart}
336
336
  ${data.week.forecastRatio !== null
337
337
  ? `<p class="muted" style="margin-top:10px">If this week repeats, the acute:chronic load ratio lands at ${data.week.forecastRatio} — ${escapeHtml(data.week.forecastVerdict.replace(/_/g, " "))}.</p>`
338
- : ""}
339
- </section>
340
-
341
- <section>
342
- <h2>Against your own baseline</h2>
343
- <h3>Resting heart rate</h3>
344
- ${rhrChart}
345
- <h3>Sleep</h3>
346
- ${sleepChart}
347
- <p class="muted" style="margin-top:8px">The dashed line is your own 30-day median. A break in the line is a day with no measurement, not a zero.</p>
348
- </section>
349
-
350
- <section>
351
- <h2>Watch pairing</h2>
352
- <div id="pairing"></div>
353
- <p class="muted" style="margin-top:8px">Open TrainBud on your watch — it shows a 6-digit code — then approve it here.</p>
354
- </section>
355
-
356
- <section>
357
- <h2>Connection</h2>
358
- <p class="muted">What the watch would see if it called right now. Everything else on this page reports on this machine, which is exactly why a dead tunnel once looked like a broken AI.</p>
359
- <div id="selftest" style="margin-top:10px"></div>
360
- <div class="actions"><button type="button" id="run-selftest">Run check</button></div>
361
- ${tunnelConfigured ? "" : `<p class="note" style="margin-top:12px">No public URL. Set <code>TRAINBUD_PUBLIC_URL</code> in .env, or run <code>scripts/start-watch-stack.ps1</code>, so the watch has somewhere to reach.</p>`}
362
- </section>
363
-
364
- <section>
365
- <h2>Settings</h2>
366
-
367
- <details>
368
- <summary>You</summary>
369
- <div class="body">
370
- <p class="muted" style="margin-bottom:12px">Garmin measures you. It has no idea what you are training for — and that is what makes a finding worth anything.</p>
371
- <form id="profile-form">
372
- <label class="field"><span>Name</span>
373
- <input type="text" name="displayName" maxlength="60" value="${escapeHtml(profile.displayName ?? "")}" placeholder="Optional"></label>
374
- <div class="grid2">
375
- <label class="field"><span>Units</span>
376
- <select name="units">
377
- <option value="metric"${profile.units === "metric" ? " selected" : ""}>Metric (km, kg)</option>
378
- <option value="imperial"${profile.units === "imperial" ? " selected" : ""}>Imperial (mi, lb)</option>
379
- </select></label>
380
- <label class="field"><span>Primary sport</span>
381
- <select name="primarySport">
382
- <option value="">Not set</option>
338
+ : ""}
339
+ </section>
340
+
341
+ <section>
342
+ <h2>Against your own baseline</h2>
343
+ <h3>Resting heart rate</h3>
344
+ ${rhrChart}
345
+ <h3>Sleep</h3>
346
+ ${sleepChart}
347
+ <p class="muted" style="margin-top:8px">The dashed line is your own 30-day median. A break in the line is a day with no measurement, not a zero.</p>
348
+ </section>
349
+
350
+ <section>
351
+ <h2>Watch pairing</h2>
352
+ <div id="pairing"></div>
353
+ <p class="muted" style="margin-top:8px">Open TrainBud on your watch — it shows a 6-digit code — then approve it here.</p>
354
+ </section>
355
+
356
+ <section>
357
+ <h2>Connection</h2>
358
+ <p class="muted">What the watch would see if it called right now. Everything else on this page reports on this machine, which is exactly why a dead tunnel once looked like a broken AI.</p>
359
+ <div id="selftest" style="margin-top:10px"></div>
360
+ <div class="actions"><button type="button" id="run-selftest">Run check</button></div>
361
+ ${tunnelConfigured ? "" : `<p class="note" style="margin-top:12px">No public URL. Set <code>TRAINBUD_PUBLIC_URL</code> in .env, or run <code>scripts/start-watch-stack.ps1</code>, so the watch has somewhere to reach.</p>`}
362
+ </section>
363
+
364
+ <section>
365
+ <h2>Settings</h2>
366
+
367
+ <details>
368
+ <summary>You</summary>
369
+ <div class="body">
370
+ <p class="muted" style="margin-bottom:12px">Garmin measures you. It has no idea what you are training for — and that is what makes a finding worth anything.</p>
371
+ <form id="profile-form">
372
+ <label class="field"><span>Name</span>
373
+ <input type="text" name="displayName" maxlength="60" value="${escapeHtml(profile.displayName ?? "")}" placeholder="Optional"></label>
374
+ <div class="grid2">
375
+ <label class="field"><span>Units</span>
376
+ <select name="units">
377
+ <option value="metric"${profile.units === "metric" ? " selected" : ""}>Metric (km, kg)</option>
378
+ <option value="imperial"${profile.units === "imperial" ? " selected" : ""}>Imperial (mi, lb)</option>
379
+ </select></label>
380
+ <label class="field"><span>Primary sport</span>
381
+ <select name="primarySport">
382
+ <option value="">Not set</option>
383
383
  ${["running", "cycling", "swimming", "strength", "mixed"]
384
384
  .map((sport) => `<option value="${sport}"${profile.primarySport === sport ? " selected" : ""}>${sport[0]?.toUpperCase()}${sport.slice(1)}</option>`)
385
- .join("")}
386
- </select></label>
387
- </div>
388
- <div class="grid2">
389
- <label class="field"><span>Sessions a week</span>
390
- <input type="number" name="weeklySessions" min="0" max="30" value="${profile.weeklyGoal.sessions ?? ""}" placeholder="—"></label>
391
- <label class="field"><span>Minutes a week</span>
392
- <input type="number" name="weeklyMinutes" min="0" max="10000" value="${profile.weeklyGoal.minutes ?? ""}" placeholder="—"></label>
393
- </div>
394
- <div class="actions"><button type="submit" class="btn-primary">Save</button></div>
395
- </form>
396
- </div>
397
- </details>
398
-
399
- <details>
400
- <summary>Goals, races and injuries</summary>
401
- <div class="body">
402
- <div id="context"></div>
403
- <form id="context-form" style="margin-top:12px">
404
- <div class="grid2">
405
- <label class="field"><span>Kind</span>
406
- <select name="kind">${CONTEXT_KINDS.map((kind) => `<option value="${kind}">${kind}</option>`).join("")}</select></label>
407
- <label class="field"><span>Date (a race is dated on the day)</span>
408
- <input type="date" name="effective_to"></label>
409
- </div>
410
- <label class="field"><span>What is it</span>
411
- <input type="text" name="text" placeholder="Half marathon, Oct 12" maxlength="200" required></label>
412
- <div class="actions"><button type="submit" class="btn-primary">Add</button></div>
413
- </form>
414
- </div>
415
- </details>
416
-
417
- <details>
418
- <summary>Your own thresholds</summary>
419
- <div class="body">
420
- <p class="muted" style="margin-bottom:12px">Where green becomes amber, and amber becomes red — on the watch as well as here. Resting heart rate is graded on the distance from your own median, never on the rate itself.</p>
421
- <form id="thresholds-form">
385
+ .join("")}
386
+ </select></label>
387
+ </div>
388
+ <div class="grid2">
389
+ <label class="field"><span>Sessions a week</span>
390
+ <input type="number" name="weeklySessions" min="0" max="30" value="${profile.weeklyGoal.sessions ?? ""}" placeholder="—"></label>
391
+ <label class="field"><span>Minutes a week</span>
392
+ <input type="number" name="weeklyMinutes" min="0" max="10000" value="${profile.weeklyGoal.minutes ?? ""}" placeholder="—"></label>
393
+ </div>
394
+ <div class="actions"><button type="submit" class="btn-primary">Save</button></div>
395
+ </form>
396
+ </div>
397
+ </details>
398
+
399
+ <details>
400
+ <summary>Goals, races and injuries</summary>
401
+ <div class="body">
402
+ <div id="context"></div>
403
+ <form id="context-form" style="margin-top:12px">
404
+ <div class="grid2">
405
+ <label class="field"><span>Kind</span>
406
+ <select name="kind">${CONTEXT_KINDS.map((kind) => `<option value="${kind}">${kind}</option>`).join("")}</select></label>
407
+ <label class="field"><span>Date (a race is dated on the day)</span>
408
+ <input type="date" name="effective_to"></label>
409
+ </div>
410
+ <label class="field"><span>What is it</span>
411
+ <input type="text" name="text" placeholder="Half marathon, Oct 12" maxlength="200" required></label>
412
+ <div class="actions"><button type="submit" class="btn-primary">Add</button></div>
413
+ </form>
414
+ </div>
415
+ </details>
416
+
417
+ <details>
418
+ <summary>Your own thresholds</summary>
419
+ <div class="body">
420
+ <p class="muted" style="margin-bottom:12px">Where green becomes amber, and amber becomes red — on the watch as well as here. Resting heart rate is graded on the distance from your own median, never on the rate itself.</p>
421
+ <form id="thresholds-form">
422
422
  ${[
423
423
  { key: "recovery", label: "Recovery score", hint: "higher is better" },
424
424
  { key: "sleepHours", label: "Sleep hours", hint: "higher is better" },
425
425
  { key: "stress", label: "Stress average", hint: "lower is better" },
426
426
  { key: "restingHrDelta", label: "Resting HR above median (bpm)", hint: "lower is better" },
427
427
  ]
428
- .map((band) => `<h3>${band.label} <span class="muted-dim">${band.hint}</span></h3>
429
- <div class="grid2">
430
- <label class="field"><span>Good at</span>
431
- <input type="number" step="0.1" name="${band.key}.good" value="${profile.thresholds[band.key].good}"></label>
432
- <label class="field"><span>Caution at</span>
433
- <input type="number" step="0.1" name="${band.key}.caution" value="${profile.thresholds[band.key].caution}"></label>
428
+ .map((band) => `<h3>${band.label} <span class="muted-dim">${band.hint}</span></h3>
429
+ <div class="grid2">
430
+ <label class="field"><span>Good at</span>
431
+ <input type="number" step="0.1" name="${band.key}.good" value="${profile.thresholds[band.key].good}"></label>
432
+ <label class="field"><span>Caution at</span>
433
+ <input type="number" step="0.1" name="${band.key}.caution" value="${profile.thresholds[band.key].caution}"></label>
434
434
  </div>`)
435
- .join("")}
436
- <div class="actions">
437
- <button type="submit" class="btn-primary">Save</button>
438
- <button type="button" id="thresholds-reset">Reset to defaults</button>
439
- </div>
440
- </form>
441
- </div>
442
- </details>
443
-
444
- <details>
445
- <summary>Watch cards</summary>
446
- <div class="body">
447
- <p class="muted" style="margin-bottom:12px">The carousel on your wrist, in this order. Live on the watch's next fetch — no Connect IQ settings, no store update.</p>
448
- <ul class="cards" id="card-list">${renderCardRows(profile.cards.order, profile.cards.hidden)}</ul>
449
- <div class="actions"><button type="button" id="cards-save" class="btn-primary">Save order</button></div>
450
- </div>
451
- </details>
452
-
453
- <details>
454
- <summary>AI</summary>
455
- <div class="body">
456
- <div class="row"><span class="label">Provider key</span>
457
- <span id="ai-status" class="${data.ai_configured ? "ok" : "warn"}">${data.ai_configured ? "configured" : "not set"}</span></div>
458
- <form id="key-form" autocomplete="off" style="margin-top:12px">
459
- <label class="field"><span>Anthropic API key</span>
460
- <input type="password" name="anthropic_api_key" placeholder="sk-ant-..." autocomplete="off"></label>
461
- <p class="muted">Stored in <code>.trainbud/app.db</code> on this machine, sent only to the provider. Leave blank to keep the current key.</p>
462
- <div class="actions">
463
- <button type="submit" class="btn-primary">Save key</button>
464
- <button type="button" id="regen">Regenerate today's insight</button>
465
- </div>
466
- </form>
467
-
468
- <form id="ai-form" style="margin-top:18px">
469
- <div class="grid2">
470
- <label class="field"><span>Model</span>
471
- <select name="model">
435
+ .join("")}
436
+ <div class="actions">
437
+ <button type="submit" class="btn-primary">Save</button>
438
+ <button type="button" id="thresholds-reset">Reset to defaults</button>
439
+ </div>
440
+ </form>
441
+ </div>
442
+ </details>
443
+
444
+ <details>
445
+ <summary>Watch cards</summary>
446
+ <div class="body">
447
+ <p class="muted" style="margin-bottom:12px">The carousel on your wrist, in this order. Live on the watch's next fetch — no Connect IQ settings, no store update.</p>
448
+ <ul class="cards" id="card-list">${renderCardRows(profile.cards.order, profile.cards.hidden)}</ul>
449
+ <div class="actions"><button type="button" id="cards-save" class="btn-primary">Save order</button></div>
450
+ </div>
451
+ </details>
452
+
453
+ <details>
454
+ <summary>AI</summary>
455
+ <div class="body">
456
+ <div class="row"><span class="label">Provider key</span>
457
+ <span id="ai-status" class="${data.ai_configured ? "ok" : "warn"}">${data.ai_configured ? "configured" : "not set"}</span></div>
458
+ <form id="key-form" autocomplete="off" style="margin-top:12px">
459
+ <label class="field"><span>Anthropic API key</span>
460
+ <input type="password" name="anthropic_api_key" placeholder="sk-ant-..." autocomplete="off"></label>
461
+ <p class="muted">Stored in <code>.trainbud/app.db</code> on this machine, sent only to the provider. Leave blank to keep the current key.</p>
462
+ <div class="actions">
463
+ <button type="submit" class="btn-primary">Save key</button>
464
+ <button type="button" id="regen">Regenerate today's insight</button>
465
+ </div>
466
+ </form>
467
+
468
+ <form id="ai-form" style="margin-top:18px">
469
+ <div class="grid2">
470
+ <label class="field"><span>Model</span>
471
+ <select name="model">
472
472
  ${[
473
473
  ["claude-haiku-4-5", "Haiku 4.5 — cheapest"],
474
474
  ["claude-sonnet-5", "Sonnet 5"],
475
475
  ["claude-opus-5", "Opus 5 — most capable"],
476
476
  ]
477
477
  .map(([id, label]) => `<option value="${id}"${profile.ai.model === id ? " selected" : ""}>${label}</option>`)
478
- .join("")}
479
- </select></label>
480
- <label class="field"><span>Answer length</span>
481
- <select name="length">
478
+ .join("")}
479
+ </select></label>
480
+ <label class="field"><span>Answer length</span>
481
+ <select name="length">
482
482
  ${[["short", "Short"], ["normal", "Normal"], ["detailed", "Detailed"]]
483
483
  .map(([id, label]) => `<option value="${id}"${profile.ai.length === id ? " selected" : ""}>${label}</option>`)
484
- .join("")}
485
- </select></label>
486
- </div>
487
- <label class="field"><span>Tone</span>
488
- <select name="tone">
484
+ .join("")}
485
+ </select></label>
486
+ </div>
487
+ <label class="field"><span>Tone</span>
488
+ <select name="tone">
489
489
  ${[
490
490
  ["direct", "Direct — no padding"],
491
491
  ["supportive", "Supportive"],
492
492
  ["technical", "Technical — name the metric"],
493
493
  ]
494
494
  .map(([id, label]) => `<option value="${id}"${profile.ai.tone === id ? " selected" : ""}>${label}</option>`)
495
- .join("")}
496
- </select></label>
497
- <div class="field">
498
- <span>Your own Ask questions</span>
499
- <ul class="cards" id="prompt-list">${renderPromptRows(profile.ai.customPrompts)}</ul>
500
- <template id="prompt-template">${promptRow("")}</template>
501
- <p class="muted" id="prompt-empty"${profile.ai.customPrompts.length ? ' hidden' : ""}>
502
- None yet — the watch offers questions drawn from what fired.
503
- </p>
504
- <div class="actions"><button type="button" id="prompt-add">Add question</button></div>
505
- </div>
506
- <p class="muted">These lead the Ask menu on your wrist, in this order, in every state.
507
- Whatever is left of the ${PROMPT_SLOTS} slots is filled from what actually fired — "Why is my
508
- resting HR up?" on the day it is. ${PROMPT_MAX_LENGTH} characters is what the watch can draw
509
- on one line; longer wraps into the next question.</p>
510
-
511
- <label class="field"><span>Monthly spending cap (USD)</span>
512
- <input type="number" name="monthlyUsd" min="0" step="0.5" value="${profile.budget.monthlyUsd ?? ""}" placeholder="No cap"></label>
513
- <p class="muted">Empty means no cap and nothing is ever refused. With a number, an Ask past the cap is refused on the watch with a message rather than silently charged.</p>
514
- <div class="actions"><button type="submit" class="btn-primary">Save</button></div>
515
- </form>
516
- </div>
517
- </details>
518
-
519
- <details>
520
- <summary>Privacy</summary>
521
- <div class="body">
522
- <form id="analytics-form">
523
- <label class="card-toggle">
524
- <input type="checkbox" name="analytics"${profile.analytics.enabled ? " checked" : ""}>
525
- <span>Count which features I use</span>
526
- </label>
527
- <p class="muted" style="margin-top:8px">Counters kept in <code>.trainbud/app.db</code> on this machine. There is no endpoint to send them to, and none is planned. Turning this off stops the counting; it does not delete what is already there.</p>
528
- <div class="actions">
529
- <button type="submit" class="btn-primary">Save</button>
530
- <button type="button" id="analytics-clear">Delete what is stored</button>
531
- </div>
532
- </form>
533
- </div>
534
- </details>
535
- </section>
536
-
537
- <section>
538
- <h2>Usage</h2>
539
- <div class="row"><span class="label">This month</span>
540
- <span>${money(data.usage.month.costUsd)}${data.usage.month.unpricedCalls > 0 ? " +" : ""} · ${data.usage.month.calls} call${data.usage.month.calls === 1 ? "" : "s"}</span></div>
541
- <div class="row"><span class="label">Monthly cap</span>
542
- <span class="${data.usage.budget.exceeded ? "err" : "muted"}">${data.usage.budget.capUsd === null ? "none set" : money(data.usage.budget.capUsd)}</span></div>
495
+ .join("")}
496
+ </select></label>
497
+ <div class="field">
498
+ <span>Your own Ask questions</span>
499
+ <ul class="cards" id="prompt-list">${renderPromptRows(profile.ai.customPrompts)}</ul>
500
+ <template id="prompt-template">${promptRow("")}</template>
501
+ <p class="muted" id="prompt-empty"${profile.ai.customPrompts.length ? ' hidden' : ""}>
502
+ None yet — the watch offers questions drawn from what fired.
503
+ </p>
504
+ <div class="actions"><button type="button" id="prompt-add">Add question</button></div>
505
+ </div>
506
+ <p class="muted">These lead the Ask menu on your wrist, in this order, in every state.
507
+ Whatever is left of the ${PROMPT_SLOTS} slots is filled from what actually fired — "Why is my
508
+ resting HR up?" on the day it is. ${PROMPT_MAX_LENGTH} characters is what the watch can draw
509
+ on one line; longer wraps into the next question.</p>
510
+
511
+ <label class="field"><span>Monthly spending cap (USD)</span>
512
+ <input type="number" name="monthlyUsd" min="0" step="0.5" value="${profile.budget.monthlyUsd ?? ""}" placeholder="No cap"></label>
513
+ <p class="muted">Empty means no cap and nothing is ever refused. With a number, an Ask past the cap is refused on the watch with a message rather than silently charged.</p>
514
+ <div class="actions"><button type="submit" class="btn-primary">Save</button></div>
515
+ </form>
516
+ </div>
517
+ </details>
518
+
519
+ <details>
520
+ <summary>Privacy</summary>
521
+ <div class="body">
522
+ <form id="analytics-form">
523
+ <label class="card-toggle">
524
+ <input type="checkbox" name="analytics"${profile.analytics.enabled ? " checked" : ""}>
525
+ <span>Count which features I use</span>
526
+ </label>
527
+ <p class="muted" style="margin-top:8px">Counters kept in <code>.trainbud/app.db</code> on this machine. There is no endpoint to send them to, and none is planned. Turning this off stops the counting; it does not delete what is already there.</p>
528
+ <div class="actions">
529
+ <button type="submit" class="btn-primary">Save</button>
530
+ <button type="button" id="analytics-clear">Delete what is stored</button>
531
+ </div>
532
+ </form>
533
+ </div>
534
+ </details>
535
+ </section>
536
+
537
+ <section>
538
+ <h2>Usage</h2>
539
+ <div class="row"><span class="label">This month</span>
540
+ <span>${money(data.usage.month.costUsd)}${data.usage.month.unpricedCalls > 0 ? " +" : ""} · ${data.usage.month.calls} call${data.usage.month.calls === 1 ? "" : "s"}</span></div>
541
+ <div class="row"><span class="label">Monthly cap</span>
542
+ <span class="${data.usage.budget.exceeded ? "err" : "muted"}">${data.usage.budget.capUsd === null ? "none set" : money(data.usage.budget.capUsd)}</span></div>
543
543
  ${data.usage.month.unpricedCalls > 0
544
544
  ? `<p class="note" style="margin-top:10px">${data.usage.month.unpricedCalls} call${data.usage.month.unpricedCalls === 1 ? "" : "s"} used a model this build has no published price for, so the total above is a floor, not a total.</p>`
545
- : ""}
546
- ${spendChart}
545
+ : ""}
546
+ ${spendChart}
547
547
  ${data.usage.features.length > 0
548
- ? `<h3>What gets used</h3>
549
- <table><thead><tr><th>Feature</th><th class="num">30 days</th></tr></thead><tbody>
548
+ ? `<h3>What gets used</h3>
549
+ <table><thead><tr><th>Feature</th><th class="num">30 days</th></tr></thead><tbody>
550
550
  ${data.usage.features
551
551
  .slice(0, 12)
552
552
  .map((feature) => `<tr><td>${escapeHtml(feature.name)}</td><td class="num">${feature.count}</td></tr>`)
553
- .join("")}
553
+ .join("")}
554
554
  </tbody></table>`
555
- : `<p class="muted" style="margin-top:10px">Nothing counted yet.</p>`}
556
- </section>
557
-
558
- <section>
559
- <h2>Watch setup</h2>
560
- <p class="muted">In the Connect IQ app, open TrainBud &rarr; settings and set Server URL to:</p>
561
- <pre id="setup-url">${escapeHtml(serverUrl)}</pre>
562
- <div class="actions"><button type="button" id="copy-url">Copy URL</button></div>
563
- </section>
564
- </div>
565
-
566
- <div id="toast" class="toast" role="status" aria-live="polite"></div>
567
-
568
- <script>
569
- // The page is reached with ?token=..., and every endpoint accepts that same
570
- // value as a Bearer header. Held in memory so no link or form has to carry
571
- // it: the old redirect-after-save dropped it and landed on a 401.
572
- var TOKEN = new URLSearchParams(location.search).get('token') || '';
573
-
574
- function authHeaders(extra) {
575
- var h = extra || {};
576
- if (TOKEN) { h['Authorization'] = 'Bearer ' + TOKEN; }
577
- return h;
578
- }
579
-
580
- function toast(message, isError) {
581
- var el = document.getElementById('toast');
582
- el.textContent = message;
583
- el.className = 'toast show' + (isError ? ' err' : '');
584
- setTimeout(function () { el.className = 'toast'; }, 2800);
585
- }
586
-
587
- function saveProfile(patch, okMessage) {
588
- return fetch('/api/profile', {
589
- method: 'PUT',
590
- headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
591
- body: JSON.stringify(patch)
592
- }).then(function (r) {
593
- return r.json().then(function (body) {
594
- // The server names the field it refused, so the message can say which
595
- // input was wrong instead of colouring the whole form red.
596
- if (!r.ok) { throw new Error(body.message || 'Could not save that'); }
597
- toast(okMessage || 'Saved');
598
- return body.profile;
599
- });
600
- }).catch(function (e) { toast(e.message, true); });
601
- }
602
-
603
- // Every value below comes from the server's own JSON, but that JSON quotes
604
- // bytes fetched from whatever answers the public URL -- a host we do not
605
- // control. textContent everywhere; innerHTML would run it.
606
- function renderSelfTest(result) {
607
- var host = document.getElementById('selftest');
608
- host.innerHTML = '';
609
- result.checks.forEach(function (check) {
610
- var row = document.createElement('div');
611
- row.className = 'row';
612
- var label = document.createElement('span');
613
- label.className = 'label';
614
- label.textContent = check.name;
615
- var state = document.createElement('span');
616
- state.className = check.ok ? 'ok' : (check.warning ? 'warn' : 'err');
617
- state.textContent = check.ok ? 'ok' : (check.warning ? 'warning' : 'failed');
618
- row.appendChild(label); row.appendChild(state); host.appendChild(row);
619
-
620
- var detail = document.createElement('p');
621
- detail.className = 'muted';
622
- detail.style.margin = '2px 0 10px';
623
- detail.textContent = check.detail + (check.fix ? ' \\u2192 ' + check.fix : '');
624
- host.appendChild(detail);
625
- });
626
- }
627
-
628
- function loadSelfTest() {
629
- var host = document.getElementById('selftest');
630
- host.textContent = 'Checking...';
631
- fetch('/dashboard/selftest', { headers: authHeaders({ 'Accept': 'application/json' }) })
632
- .then(function (r) { return r.json(); })
633
- .then(renderSelfTest)
634
- .catch(function () { toast('Could not run the check', true); });
635
- }
636
-
637
- function renderPairing(pending) {
638
- var host = document.getElementById('pairing');
639
- host.innerHTML = '';
640
- if (!pending.length) {
641
- var none = document.createElement('p');
642
- none.className = 'muted';
643
- none.textContent = 'No pending pairing requests.';
644
- host.appendChild(none);
645
- return;
646
- }
647
- pending.forEach(function (p) {
648
- var mins = Math.floor(p.expires_in / 60);
649
- var secs = p.expires_in % 60;
650
- var left = p.expires_in <= 0 ? 'expired' : (mins > 0 ? mins + 'm ' + secs + 's' : secs + 's');
651
-
652
- var card = document.createElement('div');
653
- card.className = 'pair-card';
654
- var code = document.createElement('div');
655
- code.className = 'code';
656
- code.textContent = p.code;
657
- var when = document.createElement('div');
658
- when.className = 'muted';
659
- when.textContent = 'Expires in ' + left;
660
- var btn = document.createElement('button');
661
- btn.className = 'btn-primary';
662
- btn.textContent = 'Approve';
663
- btn.addEventListener('click', function () { approve(p.code); });
664
- card.appendChild(code); card.appendChild(when); card.appendChild(btn);
665
- host.appendChild(card);
666
- });
667
- }
668
-
669
- function approve(code) {
670
- fetch('/dashboard/pair/approve', {
671
- method: 'POST',
672
- headers: authHeaders({ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }),
673
- body: 'code=' + encodeURIComponent(code)
674
- }).then(function (r) {
675
- if (!r.ok) { throw new Error('Approve failed (' + r.status + ')'); }
676
- toast('Watch paired');
677
- refresh();
678
- }).catch(function (e) { toast(e.message, true); });
679
- }
680
-
681
- function renderContext(entries) {
682
- var host = document.getElementById('context');
683
- host.innerHTML = '';
684
- if (!entries || entries.length === 0) {
685
- var none = document.createElement('p');
686
- none.className = 'muted';
687
- none.textContent = 'Nothing on record yet.';
688
- host.appendChild(none);
689
- return;
690
- }
691
- entries.forEach(function (entry) {
692
- var row = document.createElement('div');
693
- row.className = 'row';
694
- var label = document.createElement('span');
695
- label.textContent = entry.kind + ': ' + entry.text +
696
- (entry.effective_to ? ' (until ' + entry.effective_to + ')' : '');
697
- var close = document.createElement('button');
698
- close.textContent = 'Done';
699
- close.addEventListener('click', function () { closeContext(entry.id); });
700
- row.appendChild(label); row.appendChild(close);
701
- host.appendChild(row);
702
- });
703
- }
704
-
705
- function closeContext(id) {
706
- fetch('/dashboard/context/close', {
707
- method: 'POST',
708
- headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
709
- body: JSON.stringify({ id: id })
710
- }).then(function (r) {
711
- if (!r.ok) { throw new Error('Could not close that entry'); }
712
- toast('Closed');
713
- refresh();
714
- }).catch(function (e) { toast(e.message, true); });
715
- }
716
-
717
- function refresh() {
718
- fetch('/dashboard/status', { headers: authHeaders({ 'Accept': 'application/json' }) })
719
- .then(function (r) {
720
- if (!r.ok) { throw new Error('status ' + r.status); }
721
- return r.json();
722
- })
723
- .then(function (s) {
724
- renderPairing(s.pending);
725
- renderContext(s.context);
726
- var ai = document.getElementById('ai-status');
727
- ai.textContent = s.ai_configured ? 'configured' : 'not set';
728
- ai.className = s.ai_configured ? 'ok' : 'warn';
729
- })
730
- .catch(function () { /* transient - the next tick retries */ });
731
- }
732
-
733
- document.getElementById('profile-form').addEventListener('submit', function (e) {
734
- e.preventDefault();
735
- var f = e.target;
736
- function numberOrNull(input) {
737
- var raw = input.value.trim();
738
- return raw === '' ? null : Number(raw);
739
- }
740
- saveProfile({
741
- displayName: f.displayName.value.trim() || null,
742
- units: f.units.value,
743
- primarySport: f.primarySport.value || null,
744
- weeklyGoal: {
745
- sessions: numberOrNull(f.weeklySessions),
746
- minutes: numberOrNull(f.weeklyMinutes)
747
- }
748
- }, 'Profile saved');
749
- });
750
-
751
- document.getElementById('thresholds-form').addEventListener('submit', function (e) {
752
- e.preventDefault();
753
- var thresholds = {};
754
- Array.prototype.forEach.call(e.target.querySelectorAll('input[name*="."]'), function (input) {
755
- var parts = input.name.split('.');
756
- thresholds[parts[0]] = thresholds[parts[0]] || {};
757
- thresholds[parts[0]][parts[1]] = Number(input.value);
758
- });
759
- saveProfile({ thresholds: thresholds }, 'Thresholds saved');
760
- });
761
-
762
- document.getElementById('thresholds-reset').addEventListener('click', function () {
763
- fetch('/api/profile', { headers: authHeaders({ 'Accept': 'application/json' }) })
764
- .then(function (r) { return r.json(); })
765
- .then(function (body) {
766
- return saveProfile({ thresholds: body.defaults.thresholds }, 'Thresholds reset');
767
- })
768
- .then(function () { location.reload(); })
769
- .catch(function (e) { toast(e.message, true); });
770
- });
771
-
772
- (function cardOrdering() {
773
- var list = document.getElementById('card-list');
774
-
775
- function refreshMoveButtons() {
776
- var rows = list.querySelectorAll('.card-row');
777
- rows.forEach(function (row, index) {
778
- row.querySelector('[data-move="up"]').disabled = index === 0;
779
- row.querySelector('[data-move="down"]').disabled = index === rows.length - 1;
780
- });
781
- }
782
-
783
- list.addEventListener('click', function (e) {
784
- var button = e.target.closest('[data-card-move]');
785
- if (!button) { return; }
786
- var row = button.closest('.card-row');
787
- if (button.getAttribute('data-move') === 'up' && row.previousElementSibling) {
788
- list.insertBefore(row, row.previousElementSibling);
789
- } else if (button.getAttribute('data-move') === 'down' && row.nextElementSibling) {
790
- list.insertBefore(row.nextElementSibling, row);
791
- }
792
- refreshMoveButtons();
793
- });
794
-
795
- document.getElementById('cards-save').addEventListener('click', function () {
796
- var order = [];
797
- var hidden = [];
798
- list.querySelectorAll('.card-row').forEach(function (row) {
799
- var id = row.getAttribute('data-card');
800
- order.push(id);
801
- if (!row.querySelector('input[type=checkbox]').checked) { hidden.push(id); }
802
- });
803
- saveProfile({ cards: { order: order, hidden: hidden } }, 'Card order saved');
804
- });
805
- })();
806
-
807
- // The Ask menu the user writes for themselves. Rows are ordered, so the
808
- // editor needs the same up/down the card list has -- the order is what the
809
- // watch draws, and there are only ${PROMPT_SLOTS} slots to spend.
810
- var askQuestions = (function () {
811
- var list = document.getElementById('prompt-list');
812
- var template = document.getElementById('prompt-template');
813
- var addButton = document.getElementById('prompt-add');
814
- var empty = document.getElementById('prompt-empty');
815
-
816
- function rows() { return list.querySelectorAll('.prompt-row'); }
817
-
818
- function refresh() {
819
- var all = rows();
820
- all.forEach(function (row, index) {
821
- row.querySelector('[data-prompt-move="up"]').disabled = index === 0;
822
- row.querySelector('[data-prompt-move="down"]').disabled = index === all.length - 1;
823
- count(row);
824
- });
825
- // Saving is refused past the limit anyway; disabling the button says so
826
- // before the round trip instead of after it.
827
- addButton.disabled = all.length >= ${PROMPT_SLOTS};
828
- empty.hidden = all.length > 0;
829
- }
830
-
831
- function count(row) {
832
- var input = row.querySelector('.prompt-text');
833
- var label = row.querySelector('.prompt-count');
834
- var used = input.value.trim().length;
835
- label.textContent = used + '/' + ${PROMPT_MAX_LENGTH};
836
- label.className = 'prompt-count muted' + (used >= ${PROMPT_MAX_LENGTH} ? ' full' : '');
837
- }
838
-
839
- list.addEventListener('input', function (e) {
840
- var row = e.target.closest('.prompt-row');
841
- if (row) { count(row); }
842
- });
843
-
844
- list.addEventListener('click', function (e) {
845
- var move = e.target.closest('[data-prompt-move]');
846
- if (move) {
847
- var row = move.closest('.prompt-row');
848
- if (move.getAttribute('data-prompt-move') === 'up' && row.previousElementSibling) {
849
- list.insertBefore(row, row.previousElementSibling);
850
- } else if (move.getAttribute('data-prompt-move') === 'down' && row.nextElementSibling) {
851
- list.insertBefore(row.nextElementSibling, row);
852
- }
853
- refresh();
854
- return;
855
- }
856
- if (e.target.closest('[data-prompt-remove]')) {
857
- e.target.closest('.prompt-row').remove();
858
- refresh();
859
- }
860
- });
861
-
862
- addButton.addEventListener('click', function () {
863
- list.appendChild(template.content.cloneNode(true));
864
- refresh();
865
- var all = rows();
866
- all[all.length - 1].querySelector('.prompt-text').focus();
867
- });
868
-
869
- refresh();
870
-
871
- // An empty row is a row someone added and did not fill, not a question.
872
- // The schema refuses a blank, so dropping them here is the difference
873
- // between saving and a 400 naming a field the user cannot see.
874
- return function collect() {
875
- var out = [];
876
- rows().forEach(function (row) {
877
- var text = row.querySelector('.prompt-text').value.trim();
878
- if (text) { out.push(text); }
879
- });
880
- return out;
881
- };
882
- })();
883
-
884
- document.getElementById('ai-form').addEventListener('submit', function (e) {
885
- e.preventDefault();
886
- var f = e.target;
887
- var cap = f.monthlyUsd.value.trim();
888
- saveProfile({
889
- ai: {
890
- model: f.model.value,
891
- tone: f.tone.value,
892
- length: f.length.value,
893
- customPrompts: askQuestions()
894
- },
895
- budget: { monthlyUsd: cap === '' ? null : Number(cap) }
896
- }, 'AI settings saved');
897
- });
898
-
899
- document.getElementById('analytics-form').addEventListener('submit', function (e) {
900
- e.preventDefault();
901
- saveProfile({ analytics: { enabled: e.target.analytics.checked } }, 'Saved');
902
- });
903
-
904
- document.getElementById('analytics-clear').addEventListener('click', function () {
905
- fetch('/api/usage/features', { method: 'DELETE', headers: authHeaders({ 'Accept': 'application/json' }) })
906
- .then(function (r) {
907
- if (!r.ok) { throw new Error('Could not delete those counters'); }
908
- toast('Counters deleted');
909
- setTimeout(function () { location.reload(); }, 600);
910
- })
911
- .catch(function (e) { toast(e.message, true); });
912
- });
913
-
914
- document.getElementById('key-form').addEventListener('submit', function (e) {
915
- e.preventDefault();
916
- var input = e.target.elements['anthropic_api_key'];
917
- if (!input.value.trim()) { toast('Enter a key first', true); return; }
918
- fetch('/dashboard/settings', {
919
- method: 'POST',
920
- headers: authHeaders({ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }),
921
- body: 'anthropic_api_key=' + encodeURIComponent(input.value.trim())
922
- }).then(function (r) {
923
- if (!r.ok) { throw new Error('Save failed (' + r.status + ')'); }
924
- input.value = '';
925
- toast('API key saved');
926
- refresh();
927
- }).catch(function (e) { toast(e.message, true); });
928
- });
929
-
930
- document.getElementById('regen').addEventListener('click', function () {
931
- fetch('/dashboard/insight/regenerate', {
932
- method: 'POST', headers: authHeaders({ 'Accept': 'application/json' })
933
- }).then(function (r) {
934
- if (!r.ok) { throw new Error('Failed (' + r.status + ')'); }
935
- toast('Insight cleared - the next watch sync regenerates it');
936
- }).catch(function (e) { toast(e.message, true); });
937
- });
938
-
939
- document.getElementById('context-form').addEventListener('submit', function (e) {
940
- e.preventDefault();
941
- var form = e.target;
942
- fetch('/dashboard/context', {
943
- method: 'POST',
944
- headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
945
- body: JSON.stringify({
946
- kind: form.kind.value,
947
- text: form.text.value,
948
- effective_to: form.effective_to.value || undefined
949
- })
950
- }).then(function (r) {
951
- return r.json().then(function (body) {
952
- if (!r.ok) { throw new Error(body.error || 'Could not save that'); }
953
- toast('Saved');
954
- form.text.value = '';
955
- form.effective_to.value = '';
956
- refresh();
957
- });
958
- }).catch(function (e) { toast(e.message, true); });
959
- });
960
-
961
- document.getElementById('copy-url').addEventListener('click', function () {
962
- var text = document.getElementById('setup-url').textContent;
963
- navigator.clipboard.writeText(text).then(
964
- function () { toast('URL copied'); },
965
- function () { toast('Copy failed - select it manually', true); }
966
- );
967
- });
968
-
969
- document.getElementById('run-selftest').addEventListener('click', loadSelfTest);
970
-
971
- refresh();
972
- setInterval(refresh, 5000);
973
-
974
- // Once on load. It costs one outbound request and answers the question a
975
- // user arriving here most often has -- and it is deliberately NOT on the 5s
976
- // refresh, because hitting the public URL twelve times a minute forever is
977
- // how you get rate limited by your own tunnel.
978
- loadSelfTest();
979
- </script>
980
- </body>
555
+ : `<p class="muted" style="margin-top:10px">Nothing counted yet.</p>`}
556
+ </section>
557
+
558
+ <section>
559
+ <h2>Watch setup</h2>
560
+ <p class="muted">In the Connect IQ app, open TrainBud &rarr; settings and set Server URL to:</p>
561
+ <pre id="setup-url">${escapeHtml(serverUrl)}</pre>
562
+ <div class="actions"><button type="button" id="copy-url">Copy URL</button></div>
563
+ </section>
564
+ </div>
565
+
566
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
567
+
568
+ <script>
569
+ // The page is reached with ?token=..., and every endpoint accepts that same
570
+ // value as a Bearer header. Held in memory so no link or form has to carry
571
+ // it: the old redirect-after-save dropped it and landed on a 401.
572
+ var TOKEN = new URLSearchParams(location.search).get('token') || '';
573
+
574
+ function authHeaders(extra) {
575
+ var h = extra || {};
576
+ if (TOKEN) { h['Authorization'] = 'Bearer ' + TOKEN; }
577
+ return h;
578
+ }
579
+
580
+ function toast(message, isError) {
581
+ var el = document.getElementById('toast');
582
+ el.textContent = message;
583
+ el.className = 'toast show' + (isError ? ' err' : '');
584
+ setTimeout(function () { el.className = 'toast'; }, 2800);
585
+ }
586
+
587
+ function saveProfile(patch, okMessage) {
588
+ return fetch('/api/profile', {
589
+ method: 'PUT',
590
+ headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
591
+ body: JSON.stringify(patch)
592
+ }).then(function (r) {
593
+ return r.json().then(function (body) {
594
+ // The server names the field it refused, so the message can say which
595
+ // input was wrong instead of colouring the whole form red.
596
+ if (!r.ok) { throw new Error(body.message || 'Could not save that'); }
597
+ toast(okMessage || 'Saved');
598
+ return body.profile;
599
+ });
600
+ }).catch(function (e) { toast(e.message, true); });
601
+ }
602
+
603
+ // Every value below comes from the server's own JSON, but that JSON quotes
604
+ // bytes fetched from whatever answers the public URL -- a host we do not
605
+ // control. textContent everywhere; innerHTML would run it.
606
+ function renderSelfTest(result) {
607
+ var host = document.getElementById('selftest');
608
+ host.innerHTML = '';
609
+ result.checks.forEach(function (check) {
610
+ var row = document.createElement('div');
611
+ row.className = 'row';
612
+ var label = document.createElement('span');
613
+ label.className = 'label';
614
+ label.textContent = check.name;
615
+ var state = document.createElement('span');
616
+ state.className = check.ok ? 'ok' : (check.warning ? 'warn' : 'err');
617
+ state.textContent = check.ok ? 'ok' : (check.warning ? 'warning' : 'failed');
618
+ row.appendChild(label); row.appendChild(state); host.appendChild(row);
619
+
620
+ var detail = document.createElement('p');
621
+ detail.className = 'muted';
622
+ detail.style.margin = '2px 0 10px';
623
+ detail.textContent = check.detail + (check.fix ? ' \\u2192 ' + check.fix : '');
624
+ host.appendChild(detail);
625
+ });
626
+ }
627
+
628
+ function loadSelfTest() {
629
+ var host = document.getElementById('selftest');
630
+ host.textContent = 'Checking...';
631
+ fetch('/dashboard/selftest', { headers: authHeaders({ 'Accept': 'application/json' }) })
632
+ .then(function (r) { return r.json(); })
633
+ .then(renderSelfTest)
634
+ .catch(function () { toast('Could not run the check', true); });
635
+ }
636
+
637
+ function renderPairing(pending) {
638
+ var host = document.getElementById('pairing');
639
+ host.innerHTML = '';
640
+ if (!pending.length) {
641
+ var none = document.createElement('p');
642
+ none.className = 'muted';
643
+ none.textContent = 'No pending pairing requests.';
644
+ host.appendChild(none);
645
+ return;
646
+ }
647
+ pending.forEach(function (p) {
648
+ var mins = Math.floor(p.expires_in / 60);
649
+ var secs = p.expires_in % 60;
650
+ var left = p.expires_in <= 0 ? 'expired' : (mins > 0 ? mins + 'm ' + secs + 's' : secs + 's');
651
+
652
+ var card = document.createElement('div');
653
+ card.className = 'pair-card';
654
+ var code = document.createElement('div');
655
+ code.className = 'code';
656
+ code.textContent = p.code;
657
+ var when = document.createElement('div');
658
+ when.className = 'muted';
659
+ when.textContent = 'Expires in ' + left;
660
+ var btn = document.createElement('button');
661
+ btn.className = 'btn-primary';
662
+ btn.textContent = 'Approve';
663
+ btn.addEventListener('click', function () { approve(p.code); });
664
+ card.appendChild(code); card.appendChild(when); card.appendChild(btn);
665
+ host.appendChild(card);
666
+ });
667
+ }
668
+
669
+ function approve(code) {
670
+ fetch('/dashboard/pair/approve', {
671
+ method: 'POST',
672
+ headers: authHeaders({ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }),
673
+ body: 'code=' + encodeURIComponent(code)
674
+ }).then(function (r) {
675
+ if (!r.ok) { throw new Error('Approve failed (' + r.status + ')'); }
676
+ toast('Watch paired');
677
+ refresh();
678
+ }).catch(function (e) { toast(e.message, true); });
679
+ }
680
+
681
+ function renderContext(entries) {
682
+ var host = document.getElementById('context');
683
+ host.innerHTML = '';
684
+ if (!entries || entries.length === 0) {
685
+ var none = document.createElement('p');
686
+ none.className = 'muted';
687
+ none.textContent = 'Nothing on record yet.';
688
+ host.appendChild(none);
689
+ return;
690
+ }
691
+ entries.forEach(function (entry) {
692
+ var row = document.createElement('div');
693
+ row.className = 'row';
694
+ var label = document.createElement('span');
695
+ label.textContent = entry.kind + ': ' + entry.text +
696
+ (entry.effective_to ? ' (until ' + entry.effective_to + ')' : '');
697
+ var close = document.createElement('button');
698
+ close.textContent = 'Done';
699
+ close.addEventListener('click', function () { closeContext(entry.id); });
700
+ row.appendChild(label); row.appendChild(close);
701
+ host.appendChild(row);
702
+ });
703
+ }
704
+
705
+ function closeContext(id) {
706
+ fetch('/dashboard/context/close', {
707
+ method: 'POST',
708
+ headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
709
+ body: JSON.stringify({ id: id })
710
+ }).then(function (r) {
711
+ if (!r.ok) { throw new Error('Could not close that entry'); }
712
+ toast('Closed');
713
+ refresh();
714
+ }).catch(function (e) { toast(e.message, true); });
715
+ }
716
+
717
+ function refresh() {
718
+ fetch('/dashboard/status', { headers: authHeaders({ 'Accept': 'application/json' }) })
719
+ .then(function (r) {
720
+ if (!r.ok) { throw new Error('status ' + r.status); }
721
+ return r.json();
722
+ })
723
+ .then(function (s) {
724
+ renderPairing(s.pending);
725
+ renderContext(s.context);
726
+ var ai = document.getElementById('ai-status');
727
+ ai.textContent = s.ai_configured ? 'configured' : 'not set';
728
+ ai.className = s.ai_configured ? 'ok' : 'warn';
729
+ })
730
+ .catch(function () { /* transient - the next tick retries */ });
731
+ }
732
+
733
+ document.getElementById('profile-form').addEventListener('submit', function (e) {
734
+ e.preventDefault();
735
+ var f = e.target;
736
+ function numberOrNull(input) {
737
+ var raw = input.value.trim();
738
+ return raw === '' ? null : Number(raw);
739
+ }
740
+ saveProfile({
741
+ displayName: f.displayName.value.trim() || null,
742
+ units: f.units.value,
743
+ primarySport: f.primarySport.value || null,
744
+ weeklyGoal: {
745
+ sessions: numberOrNull(f.weeklySessions),
746
+ minutes: numberOrNull(f.weeklyMinutes)
747
+ }
748
+ }, 'Profile saved');
749
+ });
750
+
751
+ document.getElementById('thresholds-form').addEventListener('submit', function (e) {
752
+ e.preventDefault();
753
+ var thresholds = {};
754
+ Array.prototype.forEach.call(e.target.querySelectorAll('input[name*="."]'), function (input) {
755
+ var parts = input.name.split('.');
756
+ thresholds[parts[0]] = thresholds[parts[0]] || {};
757
+ thresholds[parts[0]][parts[1]] = Number(input.value);
758
+ });
759
+ saveProfile({ thresholds: thresholds }, 'Thresholds saved');
760
+ });
761
+
762
+ document.getElementById('thresholds-reset').addEventListener('click', function () {
763
+ fetch('/api/profile', { headers: authHeaders({ 'Accept': 'application/json' }) })
764
+ .then(function (r) { return r.json(); })
765
+ .then(function (body) {
766
+ return saveProfile({ thresholds: body.defaults.thresholds }, 'Thresholds reset');
767
+ })
768
+ .then(function () { location.reload(); })
769
+ .catch(function (e) { toast(e.message, true); });
770
+ });
771
+
772
+ (function cardOrdering() {
773
+ var list = document.getElementById('card-list');
774
+
775
+ function refreshMoveButtons() {
776
+ var rows = list.querySelectorAll('.card-row');
777
+ rows.forEach(function (row, index) {
778
+ row.querySelector('[data-move="up"]').disabled = index === 0;
779
+ row.querySelector('[data-move="down"]').disabled = index === rows.length - 1;
780
+ });
781
+ }
782
+
783
+ list.addEventListener('click', function (e) {
784
+ var button = e.target.closest('[data-card-move]');
785
+ if (!button) { return; }
786
+ var row = button.closest('.card-row');
787
+ if (button.getAttribute('data-move') === 'up' && row.previousElementSibling) {
788
+ list.insertBefore(row, row.previousElementSibling);
789
+ } else if (button.getAttribute('data-move') === 'down' && row.nextElementSibling) {
790
+ list.insertBefore(row.nextElementSibling, row);
791
+ }
792
+ refreshMoveButtons();
793
+ });
794
+
795
+ document.getElementById('cards-save').addEventListener('click', function () {
796
+ var order = [];
797
+ var hidden = [];
798
+ list.querySelectorAll('.card-row').forEach(function (row) {
799
+ var id = row.getAttribute('data-card');
800
+ order.push(id);
801
+ if (!row.querySelector('input[type=checkbox]').checked) { hidden.push(id); }
802
+ });
803
+ saveProfile({ cards: { order: order, hidden: hidden } }, 'Card order saved');
804
+ });
805
+ })();
806
+
807
+ // The Ask menu the user writes for themselves. Rows are ordered, so the
808
+ // editor needs the same up/down the card list has -- the order is what the
809
+ // watch draws, and there are only ${PROMPT_SLOTS} slots to spend.
810
+ var askQuestions = (function () {
811
+ var list = document.getElementById('prompt-list');
812
+ var template = document.getElementById('prompt-template');
813
+ var addButton = document.getElementById('prompt-add');
814
+ var empty = document.getElementById('prompt-empty');
815
+
816
+ function rows() { return list.querySelectorAll('.prompt-row'); }
817
+
818
+ function refresh() {
819
+ var all = rows();
820
+ all.forEach(function (row, index) {
821
+ row.querySelector('[data-prompt-move="up"]').disabled = index === 0;
822
+ row.querySelector('[data-prompt-move="down"]').disabled = index === all.length - 1;
823
+ count(row);
824
+ });
825
+ // Saving is refused past the limit anyway; disabling the button says so
826
+ // before the round trip instead of after it.
827
+ addButton.disabled = all.length >= ${PROMPT_SLOTS};
828
+ empty.hidden = all.length > 0;
829
+ }
830
+
831
+ function count(row) {
832
+ var input = row.querySelector('.prompt-text');
833
+ var label = row.querySelector('.prompt-count');
834
+ var used = input.value.trim().length;
835
+ label.textContent = used + '/' + ${PROMPT_MAX_LENGTH};
836
+ label.className = 'prompt-count muted' + (used >= ${PROMPT_MAX_LENGTH} ? ' full' : '');
837
+ }
838
+
839
+ list.addEventListener('input', function (e) {
840
+ var row = e.target.closest('.prompt-row');
841
+ if (row) { count(row); }
842
+ });
843
+
844
+ list.addEventListener('click', function (e) {
845
+ var move = e.target.closest('[data-prompt-move]');
846
+ if (move) {
847
+ var row = move.closest('.prompt-row');
848
+ if (move.getAttribute('data-prompt-move') === 'up' && row.previousElementSibling) {
849
+ list.insertBefore(row, row.previousElementSibling);
850
+ } else if (move.getAttribute('data-prompt-move') === 'down' && row.nextElementSibling) {
851
+ list.insertBefore(row.nextElementSibling, row);
852
+ }
853
+ refresh();
854
+ return;
855
+ }
856
+ if (e.target.closest('[data-prompt-remove]')) {
857
+ e.target.closest('.prompt-row').remove();
858
+ refresh();
859
+ }
860
+ });
861
+
862
+ addButton.addEventListener('click', function () {
863
+ list.appendChild(template.content.cloneNode(true));
864
+ refresh();
865
+ var all = rows();
866
+ all[all.length - 1].querySelector('.prompt-text').focus();
867
+ });
868
+
869
+ refresh();
870
+
871
+ // An empty row is a row someone added and did not fill, not a question.
872
+ // The schema refuses a blank, so dropping them here is the difference
873
+ // between saving and a 400 naming a field the user cannot see.
874
+ return function collect() {
875
+ var out = [];
876
+ rows().forEach(function (row) {
877
+ var text = row.querySelector('.prompt-text').value.trim();
878
+ if (text) { out.push(text); }
879
+ });
880
+ return out;
881
+ };
882
+ })();
883
+
884
+ document.getElementById('ai-form').addEventListener('submit', function (e) {
885
+ e.preventDefault();
886
+ var f = e.target;
887
+ var cap = f.monthlyUsd.value.trim();
888
+ saveProfile({
889
+ ai: {
890
+ model: f.model.value,
891
+ tone: f.tone.value,
892
+ length: f.length.value,
893
+ customPrompts: askQuestions()
894
+ },
895
+ budget: { monthlyUsd: cap === '' ? null : Number(cap) }
896
+ }, 'AI settings saved');
897
+ });
898
+
899
+ document.getElementById('analytics-form').addEventListener('submit', function (e) {
900
+ e.preventDefault();
901
+ saveProfile({ analytics: { enabled: e.target.analytics.checked } }, 'Saved');
902
+ });
903
+
904
+ document.getElementById('analytics-clear').addEventListener('click', function () {
905
+ fetch('/api/usage/features', { method: 'DELETE', headers: authHeaders({ 'Accept': 'application/json' }) })
906
+ .then(function (r) {
907
+ if (!r.ok) { throw new Error('Could not delete those counters'); }
908
+ toast('Counters deleted');
909
+ setTimeout(function () { location.reload(); }, 600);
910
+ })
911
+ .catch(function (e) { toast(e.message, true); });
912
+ });
913
+
914
+ document.getElementById('key-form').addEventListener('submit', function (e) {
915
+ e.preventDefault();
916
+ var input = e.target.elements['anthropic_api_key'];
917
+ if (!input.value.trim()) { toast('Enter a key first', true); return; }
918
+ fetch('/dashboard/settings', {
919
+ method: 'POST',
920
+ headers: authHeaders({ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }),
921
+ body: 'anthropic_api_key=' + encodeURIComponent(input.value.trim())
922
+ }).then(function (r) {
923
+ if (!r.ok) { throw new Error('Save failed (' + r.status + ')'); }
924
+ input.value = '';
925
+ toast('API key saved');
926
+ refresh();
927
+ }).catch(function (e) { toast(e.message, true); });
928
+ });
929
+
930
+ document.getElementById('regen').addEventListener('click', function () {
931
+ fetch('/dashboard/insight/regenerate', {
932
+ method: 'POST', headers: authHeaders({ 'Accept': 'application/json' })
933
+ }).then(function (r) {
934
+ if (!r.ok) { throw new Error('Failed (' + r.status + ')'); }
935
+ toast('Insight cleared - the next watch sync regenerates it');
936
+ }).catch(function (e) { toast(e.message, true); });
937
+ });
938
+
939
+ document.getElementById('context-form').addEventListener('submit', function (e) {
940
+ e.preventDefault();
941
+ var form = e.target;
942
+ fetch('/dashboard/context', {
943
+ method: 'POST',
944
+ headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
945
+ body: JSON.stringify({
946
+ kind: form.kind.value,
947
+ text: form.text.value,
948
+ effective_to: form.effective_to.value || undefined
949
+ })
950
+ }).then(function (r) {
951
+ return r.json().then(function (body) {
952
+ if (!r.ok) { throw new Error(body.error || 'Could not save that'); }
953
+ toast('Saved');
954
+ form.text.value = '';
955
+ form.effective_to.value = '';
956
+ refresh();
957
+ });
958
+ }).catch(function (e) { toast(e.message, true); });
959
+ });
960
+
961
+ document.getElementById('copy-url').addEventListener('click', function () {
962
+ var text = document.getElementById('setup-url').textContent;
963
+ navigator.clipboard.writeText(text).then(
964
+ function () { toast('URL copied'); },
965
+ function () { toast('Copy failed - select it manually', true); }
966
+ );
967
+ });
968
+
969
+ document.getElementById('run-selftest').addEventListener('click', loadSelfTest);
970
+
971
+ refresh();
972
+ setInterval(refresh, 5000);
973
+
974
+ // Once on load. It costs one outbound request and answers the question a
975
+ // user arriving here most often has -- and it is deliberately NOT on the 5s
976
+ // refresh, because hitting the public URL twelve times a minute forever is
977
+ // how you get rate limited by your own tunnel.
978
+ loadSelfTest();
979
+ </script>
980
+ </body>
981
981
  </html>`;
982
982
  }
983
983
  export function renderPairSuccess(code, token) {
984
984
  const back = token ? `/dashboard?token=${encodeURIComponent(token)}` : "/dashboard";
985
- return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
986
- <meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Paired</title>
987
- <style>body{font-family:ui-sans-serif,system-ui,sans-serif;background:${PALETTE.ground};color:${PALETTE.ink};
988
- display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px;padding:24px;text-align:center;}
989
- .mark{font-size:2.6rem;color:${PALETTE.good};} p{color:${PALETTE.muted};} a{color:${PALETTE.series};}</style></head>
990
- <body><div class="mark">&#10003;</div><p>Watch code <strong style="color:${PALETTE.ink}">${escapeHtml(code)}</strong> approved. Your watch will connect shortly.</p>
985
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
986
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Paired</title>
987
+ <style>body{font-family:ui-sans-serif,system-ui,sans-serif;background:${PALETTE.ground};color:${PALETTE.ink};
988
+ display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px;padding:24px;text-align:center;}
989
+ .mark{font-size:2.6rem;color:${PALETTE.good};} p{color:${PALETTE.muted};} a{color:${PALETTE.series};}</style></head>
990
+ <body><div class="mark">&#10003;</div><p>Watch code <strong style="color:${PALETTE.ink}">${escapeHtml(code)}</strong> approved. Your watch will connect shortly.</p>
991
991
  <a href="${escapeHtml(back)}">&larr; Back to dashboard</a></body></html>`;
992
992
  }
993
993
  export function renderPairError(msg, token) {
994
994
  const back = token ? `/dashboard?token=${encodeURIComponent(token)}` : "/dashboard";
995
- return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
996
- <meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Error</title>
997
- <style>body{font-family:ui-sans-serif,system-ui,sans-serif;background:${PALETTE.ground};color:${PALETTE.ink};
998
- display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px;padding:24px;text-align:center;}
999
- .mark{font-size:2.6rem;color:${PALETTE.hard};} p{color:${PALETTE.muted};} a{color:${PALETTE.series};}</style></head>
1000
- <body><div class="mark">&#10007;</div><p>${escapeHtml(msg)}</p>
995
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
996
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Error</title>
997
+ <style>body{font-family:ui-sans-serif,system-ui,sans-serif;background:${PALETTE.ground};color:${PALETTE.ink};
998
+ display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px;padding:24px;text-align:center;}
999
+ .mark{font-size:2.6rem;color:${PALETTE.hard};} p{color:${PALETTE.muted};} a{color:${PALETTE.series};}</style></head>
1000
+ <body><div class="mark">&#10007;</div><p>${escapeHtml(msg)}</p>
1001
1001
  <a href="${escapeHtml(back)}">&larr; Back to dashboard</a></body></html>`;
1002
1002
  }
1003
1003
  export { CARD_IDS };