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
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
import { CARD_IDS } from "./profile.js";
|
|
2
|
+
import { PROMPT_MAX_LENGTH, PROMPT_SLOTS } from "./promptSuggestions.js";
|
|
3
|
+
import { CONTEXT_KINDS } from "./history/schema.js";
|
|
4
|
+
import { appConfig } from "./config.js";
|
|
5
|
+
import { columnChart, dumbbellChart, lineChart } from "./dashboardCharts.js";
|
|
6
|
+
import { getDashboardData } from "./dashboardData.js";
|
|
7
|
+
export { getDashboardStatus } from "./dashboardData.js";
|
|
8
|
+
// SECTION: HTML dashboard
|
|
9
|
+
//
|
|
10
|
+
// One page, one column, phone first. The pairing flow is the reason: the user
|
|
11
|
+
// is standing next to the watch holding a phone when they approve a code, and
|
|
12
|
+
// the old 560px desktop column was the only layout anyone had considered.
|
|
13
|
+
//
|
|
14
|
+
// Everything after first paint comes from /dashboard/status over fetch. A
|
|
15
|
+
// full-page reload every ten seconds used to discard whatever was half-typed
|
|
16
|
+
// into the API key field.
|
|
17
|
+
//
|
|
18
|
+
// Colour rule, and it is the whole design: GREEN, AMBER AND RED MEAN A STATE
|
|
19
|
+
// AND NOTHING ELSE. They never decorate a heading, a border or a button. The
|
|
20
|
+
// brand lives in the type, the spacing and the page dots. This matters more
|
|
21
|
+
// here than it would elsewhere because the same three colours are drawn on a
|
|
22
|
+
// watch face from the same server, and a user who learns that amber means
|
|
23
|
+
// "look at this" must not then meet amber as a button.
|
|
24
|
+
const PALETTE = {
|
|
25
|
+
ground: "#0D1220",
|
|
26
|
+
surface: "#141C2E",
|
|
27
|
+
raised: "#1B2540",
|
|
28
|
+
line: "#22304A",
|
|
29
|
+
ink: "#E6EDF5",
|
|
30
|
+
muted: "#8FA3BD",
|
|
31
|
+
series: "#3987E5",
|
|
32
|
+
// See dashboardCharts.ts: this green is chosen to survive an 8-colour watch.
|
|
33
|
+
good: "#4CD964",
|
|
34
|
+
caution: "#F5A623",
|
|
35
|
+
hard: "#E5484D",
|
|
36
|
+
};
|
|
37
|
+
function escapeHtml(str) {
|
|
38
|
+
return str
|
|
39
|
+
.replace(/&/g, "&")
|
|
40
|
+
.replace(/</g, "<")
|
|
41
|
+
.replace(/>/g, ">")
|
|
42
|
+
.replace(/"/g, """);
|
|
43
|
+
}
|
|
44
|
+
const CARD_LABELS = {
|
|
45
|
+
today: "Today",
|
|
46
|
+
ask: "Ask AI",
|
|
47
|
+
insight: "AI insight",
|
|
48
|
+
week: "Week",
|
|
49
|
+
overview: "Overview",
|
|
50
|
+
recovery: "Recovery",
|
|
51
|
+
sleep: "Sleep",
|
|
52
|
+
activity: "Activity",
|
|
53
|
+
stress: "Stress",
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* A tile carries the state as a dot AND a word, never as colour alone.
|
|
57
|
+
*
|
|
58
|
+
* The value itself stays in ink. A light status hue is illegible as text on
|
|
59
|
+
* this surface, and colouring the number would mean the identity channel is
|
|
60
|
+
* doing two jobs at once.
|
|
61
|
+
*/
|
|
62
|
+
function renderTile(tile) {
|
|
63
|
+
const stateWord = tile.state === "good"
|
|
64
|
+
? "in range"
|
|
65
|
+
: tile.state === "caution"
|
|
66
|
+
? "watch"
|
|
67
|
+
: tile.state === "hard"
|
|
68
|
+
? "off baseline"
|
|
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"> </div>`}
|
|
74
|
+
${stateWord
|
|
75
|
+
? `<div class="tile-state"><span class="dot dot-${tile.state}"></span>${stateWord}</div>`
|
|
76
|
+
: `<div class="tile-state muted-dim">not graded</div>`}
|
|
77
|
+
</div>`;
|
|
78
|
+
}
|
|
79
|
+
function severityWord(severity) {
|
|
80
|
+
return severity === "warn" ? "hard" : severity === "notice" ? "caution" : "good";
|
|
81
|
+
}
|
|
82
|
+
function renderToday(data) {
|
|
83
|
+
if (data.coverageNote) {
|
|
84
|
+
// An empty findings list means two opposite things and the page has to say
|
|
85
|
+
// which. Silence here would read as a clean bill of health for days that
|
|
86
|
+
// were never recorded.
|
|
87
|
+
return `<p class="note">${escapeHtml(data.coverageNote)}</p>`;
|
|
88
|
+
}
|
|
89
|
+
if (data.findings.length === 0) {
|
|
90
|
+
return `<p class="hero-line">Nothing stands out today.</p>
|
|
91
|
+
<p class="muted">Measured against your own baselines over ${data.coverageDays} days.</p>`;
|
|
92
|
+
}
|
|
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>
|
|
97
|
+
</div>`)
|
|
98
|
+
.join("");
|
|
99
|
+
}
|
|
100
|
+
function renderCardRows(order, hidden) {
|
|
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">↑</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">↓</button>
|
|
110
|
+
</span>
|
|
111
|
+
</li>`)
|
|
112
|
+
.join("");
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* One row of the Ask-menu editor.
|
|
116
|
+
*
|
|
117
|
+
* Rendered server-side for the questions that exist and once more into a
|
|
118
|
+
* `<template>`, so the Add button clones the same markup rather than a second
|
|
119
|
+
* copy of it written in the client script. Two copies of a row is how an
|
|
120
|
+
* attribute gets fixed in one place and not the other.
|
|
121
|
+
*
|
|
122
|
+
* The move buttons carry no `disabled` here: the client recomputes them on
|
|
123
|
+
* load, on add and on remove, and one owner of that rule is enough.
|
|
124
|
+
*/
|
|
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">↑</button>
|
|
133
|
+
<button type="button" class="btn-icon" data-move="down" data-prompt-move="down" aria-label="Move down">↓</button>
|
|
134
|
+
<button type="button" class="btn-icon" data-prompt-remove aria-label="Remove">×</button>
|
|
135
|
+
</span>
|
|
136
|
+
</li>`;
|
|
137
|
+
}
|
|
138
|
+
function renderPromptRows(prompts) {
|
|
139
|
+
return prompts.map((text) => promptRow(text)).join("");
|
|
140
|
+
}
|
|
141
|
+
function money(value) {
|
|
142
|
+
return `$${value.toFixed(2)}`;
|
|
143
|
+
}
|
|
144
|
+
export function renderDashboard(publicUrl) {
|
|
145
|
+
const data = getDashboardData(publicUrl);
|
|
146
|
+
const serverUrl = data.public_url || `http://localhost:${appConfig.mcpPort}`;
|
|
147
|
+
const tunnelConfigured = data.public_url.length > 0;
|
|
148
|
+
const profile = data.profile;
|
|
149
|
+
const weekChart = dumbbellChart(data.week.rows, { width: 320 });
|
|
150
|
+
const rhrChart = lineChart(data.trends.restingHr.points, {
|
|
151
|
+
label: "Resting heart rate, 30 days",
|
|
152
|
+
unit: " bpm",
|
|
153
|
+
baseline: data.trends.restingHr.baseline === null
|
|
154
|
+
? null
|
|
155
|
+
: { value: data.trends.restingHr.baseline, label: "your 30-day median" },
|
|
156
|
+
format: (value) => String(Math.round(value)),
|
|
157
|
+
});
|
|
158
|
+
const sleepChart = lineChart(data.trends.sleep.points, {
|
|
159
|
+
label: "Sleep, 30 days",
|
|
160
|
+
unit: " h",
|
|
161
|
+
baseline: data.trends.sleep.baseline === null
|
|
162
|
+
? null
|
|
163
|
+
: { value: data.trends.sleep.baseline, label: "your usual night" },
|
|
164
|
+
format: (value) => value.toFixed(1),
|
|
165
|
+
});
|
|
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)}
|
|
323
|
+
${data.race
|
|
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}
|
|
336
|
+
${data.week.forecastRatio !== null
|
|
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>
|
|
383
|
+
${["running", "cycling", "swimming", "strength", "mixed"]
|
|
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">
|
|
422
|
+
${[
|
|
423
|
+
{ key: "recovery", label: "Recovery score", hint: "higher is better" },
|
|
424
|
+
{ key: "sleepHours", label: "Sleep hours", hint: "higher is better" },
|
|
425
|
+
{ key: "stress", label: "Stress average", hint: "lower is better" },
|
|
426
|
+
{ key: "restingHrDelta", label: "Resting HR above median (bpm)", hint: "lower is better" },
|
|
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>
|
|
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">
|
|
472
|
+
${[
|
|
473
|
+
["claude-haiku-4-5", "Haiku 4.5 — cheapest"],
|
|
474
|
+
["claude-sonnet-5", "Sonnet 5"],
|
|
475
|
+
["claude-opus-5", "Opus 5 — most capable"],
|
|
476
|
+
]
|
|
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">
|
|
482
|
+
${[["short", "Short"], ["normal", "Normal"], ["detailed", "Detailed"]]
|
|
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">
|
|
489
|
+
${[
|
|
490
|
+
["direct", "Direct — no padding"],
|
|
491
|
+
["supportive", "Supportive"],
|
|
492
|
+
["technical", "Technical — name the metric"],
|
|
493
|
+
]
|
|
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>
|
|
543
|
+
${data.usage.month.unpricedCalls > 0
|
|
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}
|
|
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>
|
|
550
|
+
${data.usage.features
|
|
551
|
+
.slice(0, 12)
|
|
552
|
+
.map((feature) => `<tr><td>${escapeHtml(feature.name)}</td><td class="num">${feature.count}</td></tr>`)
|
|
553
|
+
.join("")}
|
|
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 → 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
|
+
</html>`;
|
|
982
|
+
}
|
|
983
|
+
export function renderPairSuccess(code, token) {
|
|
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">✓</div><p>Watch code <strong style="color:${PALETTE.ink}">${escapeHtml(code)}</strong> approved. Your watch will connect shortly.</p>
|
|
991
|
+
<a href="${escapeHtml(back)}">← Back to dashboard</a></body></html>`;
|
|
992
|
+
}
|
|
993
|
+
export function renderPairError(msg, token) {
|
|
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">✗</div><p>${escapeHtml(msg)}</p>
|
|
1001
|
+
<a href="${escapeHtml(back)}">← Back to dashboard</a></body></html>`;
|
|
1002
|
+
}
|
|
1003
|
+
export { CARD_IDS };
|
|
1004
|
+
//# sourceMappingURL=dashboard.js.map
|