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.
Files changed (274) hide show
  1. package/.env.example +11 -0
  2. package/CHANGELOG.md +675 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +182 -0
  5. package/README.md +322 -0
  6. package/dist/appDb.d.ts +83 -0
  7. package/dist/appDb.d.ts.map +1 -0
  8. package/dist/appDb.js +239 -0
  9. package/dist/appDb.js.map +1 -0
  10. package/dist/check.d.ts +12 -0
  11. package/dist/check.d.ts.map +1 -0
  12. package/dist/check.js +230 -0
  13. package/dist/check.js.map +1 -0
  14. package/dist/cli.d.ts +3 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +457 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/config.d.ts +61 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +241 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/dashboard.d.ts +8 -0
  23. package/dist/dashboard.d.ts.map +1 -0
  24. package/dist/dashboard.js +1004 -0
  25. package/dist/dashboard.js.map +1 -0
  26. package/dist/dashboardCharts.d.ts +99 -0
  27. package/dist/dashboardCharts.d.ts.map +1 -0
  28. package/dist/dashboardCharts.js +317 -0
  29. package/dist/dashboardCharts.js.map +1 -0
  30. package/dist/dashboardData.d.ts +89 -0
  31. package/dist/dashboardData.d.ts.map +1 -0
  32. package/dist/dashboardData.js +228 -0
  33. package/dist/dashboardData.js.map +1 -0
  34. package/dist/detect/baseline.d.ts +17 -0
  35. package/dist/detect/baseline.d.ts.map +1 -0
  36. package/dist/detect/baseline.js +62 -0
  37. package/dist/detect/baseline.js.map +1 -0
  38. package/dist/detect/countdown.d.ts +30 -0
  39. package/dist/detect/countdown.d.ts.map +1 -0
  40. package/dist/detect/countdown.js +79 -0
  41. package/dist/detect/countdown.js.map +1 -0
  42. package/dist/detect/detectors.d.ts +20 -0
  43. package/dist/detect/detectors.d.ts.map +1 -0
  44. package/dist/detect/detectors.js +291 -0
  45. package/dist/detect/detectors.js.map +1 -0
  46. package/dist/detect/findings.d.ts +29 -0
  47. package/dist/detect/findings.d.ts.map +1 -0
  48. package/dist/detect/findings.js +2 -0
  49. package/dist/detect/findings.js.map +1 -0
  50. package/dist/detect/forecast.d.ts +33 -0
  51. package/dist/detect/forecast.d.ts.map +1 -0
  52. package/dist/detect/forecast.js +122 -0
  53. package/dist/detect/forecast.js.map +1 -0
  54. package/dist/detect/index.d.ts +50 -0
  55. package/dist/detect/index.d.ts.map +1 -0
  56. package/dist/detect/index.js +114 -0
  57. package/dist/detect/index.js.map +1 -0
  58. package/dist/detect/sleepQuality.d.ts +21 -0
  59. package/dist/detect/sleepQuality.d.ts.map +1 -0
  60. package/dist/detect/sleepQuality.js +164 -0
  61. package/dist/detect/sleepQuality.js.map +1 -0
  62. package/dist/detect/trimp.d.ts +22 -0
  63. package/dist/detect/trimp.d.ts.map +1 -0
  64. package/dist/detect/trimp.js +77 -0
  65. package/dist/detect/trimp.js.map +1 -0
  66. package/dist/detect/week.d.ts +31 -0
  67. package/dist/detect/week.d.ts.map +1 -0
  68. package/dist/detect/week.js +184 -0
  69. package/dist/detect/week.js.map +1 -0
  70. package/dist/garmin/auth.d.ts +10 -0
  71. package/dist/garmin/auth.d.ts.map +1 -0
  72. package/dist/garmin/auth.js +81 -0
  73. package/dist/garmin/auth.js.map +1 -0
  74. package/dist/garmin/cache.d.ts +42 -0
  75. package/dist/garmin/cache.d.ts.map +1 -0
  76. package/dist/garmin/cache.js +141 -0
  77. package/dist/garmin/cache.js.map +1 -0
  78. package/dist/garmin/client.d.ts +22 -0
  79. package/dist/garmin/client.d.ts.map +1 -0
  80. package/dist/garmin/client.js +237 -0
  81. package/dist/garmin/client.js.map +1 -0
  82. package/dist/garmin/daily.d.ts +24 -0
  83. package/dist/garmin/daily.d.ts.map +1 -0
  84. package/dist/garmin/daily.js +96 -0
  85. package/dist/garmin/daily.js.map +1 -0
  86. package/dist/garmin/garminApiTypes.d.ts +70 -0
  87. package/dist/garmin/garminApiTypes.d.ts.map +1 -0
  88. package/dist/garmin/garminApiTypes.js +3 -0
  89. package/dist/garmin/garminApiTypes.js.map +1 -0
  90. package/dist/garmin/garminConnect.d.ts +17 -0
  91. package/dist/garmin/garminConnect.d.ts.map +1 -0
  92. package/dist/garmin/garminConnect.js +5 -0
  93. package/dist/garmin/garminConnect.js.map +1 -0
  94. package/dist/garmin/partial.d.ts +32 -0
  95. package/dist/garmin/partial.d.ts.map +1 -0
  96. package/dist/garmin/partial.js +38 -0
  97. package/dist/garmin/partial.js.map +1 -0
  98. package/dist/garmin/rawApi.d.ts +59 -0
  99. package/dist/garmin/rawApi.d.ts.map +1 -0
  100. package/dist/garmin/rawApi.js +113 -0
  101. package/dist/garmin/rawApi.js.map +1 -0
  102. package/dist/garmin/types.d.ts +85 -0
  103. package/dist/garmin/types.d.ts.map +1 -0
  104. package/dist/garmin/types.js +11 -0
  105. package/dist/garmin/types.js.map +1 -0
  106. package/dist/history/capture.d.ts +12 -0
  107. package/dist/history/capture.d.ts.map +1 -0
  108. package/dist/history/capture.js +63 -0
  109. package/dist/history/capture.js.map +1 -0
  110. package/dist/history/context.d.ts +51 -0
  111. package/dist/history/context.d.ts.map +1 -0
  112. package/dist/history/context.js +122 -0
  113. package/dist/history/context.js.map +1 -0
  114. package/dist/history/fallback.d.ts +72 -0
  115. package/dist/history/fallback.d.ts.map +1 -0
  116. package/dist/history/fallback.js +185 -0
  117. package/dist/history/fallback.js.map +1 -0
  118. package/dist/history/ingest.d.ts +44 -0
  119. package/dist/history/ingest.d.ts.map +1 -0
  120. package/dist/history/ingest.js +347 -0
  121. package/dist/history/ingest.js.map +1 -0
  122. package/dist/history/scheduler.d.ts +20 -0
  123. package/dist/history/scheduler.d.ts.map +1 -0
  124. package/dist/history/scheduler.js +54 -0
  125. package/dist/history/scheduler.js.map +1 -0
  126. package/dist/history/schema.d.ts +41 -0
  127. package/dist/history/schema.d.ts.map +1 -0
  128. package/dist/history/schema.js +91 -0
  129. package/dist/history/schema.js.map +1 -0
  130. package/dist/history/store.d.ts +112 -0
  131. package/dist/history/store.d.ts.map +1 -0
  132. package/dist/history/store.js +282 -0
  133. package/dist/history/store.js.map +1 -0
  134. package/dist/httpServer.d.ts +55 -0
  135. package/dist/httpServer.d.ts.map +1 -0
  136. package/dist/httpServer.js +1027 -0
  137. package/dist/httpServer.js.map +1 -0
  138. package/dist/index.d.ts +3 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +11 -0
  141. package/dist/index.js.map +1 -0
  142. package/dist/mcpConfig.d.ts +56 -0
  143. package/dist/mcpConfig.d.ts.map +1 -0
  144. package/dist/mcpConfig.js +106 -0
  145. package/dist/mcpConfig.js.map +1 -0
  146. package/dist/pairApi.d.ts +14 -0
  147. package/dist/pairApi.d.ts.map +1 -0
  148. package/dist/pairApi.js +38 -0
  149. package/dist/pairApi.js.map +1 -0
  150. package/dist/paths.d.ts +45 -0
  151. package/dist/paths.d.ts.map +1 -0
  152. package/dist/paths.js +105 -0
  153. package/dist/paths.js.map +1 -0
  154. package/dist/profile.d.ts +123 -0
  155. package/dist/profile.d.ts.map +1 -0
  156. package/dist/profile.js +327 -0
  157. package/dist/profile.js.map +1 -0
  158. package/dist/promptApi.d.ts +43 -0
  159. package/dist/promptApi.d.ts.map +1 -0
  160. package/dist/promptApi.js +328 -0
  161. package/dist/promptApi.js.map +1 -0
  162. package/dist/promptSuggestions.d.ts +16 -0
  163. package/dist/promptSuggestions.d.ts.map +1 -0
  164. package/dist/promptSuggestions.js +141 -0
  165. package/dist/promptSuggestions.js.map +1 -0
  166. package/dist/selfTest.d.ts +53 -0
  167. package/dist/selfTest.d.ts.map +1 -0
  168. package/dist/selfTest.js +225 -0
  169. package/dist/selfTest.js.map +1 -0
  170. package/dist/server.d.ts +10 -0
  171. package/dist/server.d.ts.map +1 -0
  172. package/dist/server.js +67 -0
  173. package/dist/server.js.map +1 -0
  174. package/dist/setup.d.ts +2 -0
  175. package/dist/setup.d.ts.map +1 -0
  176. package/dist/setup.js +210 -0
  177. package/dist/setup.js.map +1 -0
  178. package/dist/toolErrors.d.ts +2 -0
  179. package/dist/toolErrors.d.ts.map +1 -0
  180. package/dist/toolErrors.js +18 -0
  181. package/dist/toolErrors.js.map +1 -0
  182. package/dist/tools/activities.d.ts +18 -0
  183. package/dist/tools/activities.d.ts.map +1 -0
  184. package/dist/tools/activities.js +182 -0
  185. package/dist/tools/activities.js.map +1 -0
  186. package/dist/tools/bodyComposition.d.ts +10 -0
  187. package/dist/tools/bodyComposition.d.ts.map +1 -0
  188. package/dist/tools/bodyComposition.js +139 -0
  189. package/dist/tools/bodyComposition.js.map +1 -0
  190. package/dist/tools/context.d.ts +8 -0
  191. package/dist/tools/context.d.ts.map +1 -0
  192. package/dist/tools/context.js +148 -0
  193. package/dist/tools/context.js.map +1 -0
  194. package/dist/tools/findings.d.ts +7 -0
  195. package/dist/tools/findings.d.ts.map +1 -0
  196. package/dist/tools/findings.js +51 -0
  197. package/dist/tools/findings.js.map +1 -0
  198. package/dist/tools/heartRate.d.ts +10 -0
  199. package/dist/tools/heartRate.d.ts.map +1 -0
  200. package/dist/tools/heartRate.js +105 -0
  201. package/dist/tools/heartRate.js.map +1 -0
  202. package/dist/tools/index.d.ts +66 -0
  203. package/dist/tools/index.d.ts.map +1 -0
  204. package/dist/tools/index.js +101 -0
  205. package/dist/tools/index.js.map +1 -0
  206. package/dist/tools/payloads.d.ts +148 -0
  207. package/dist/tools/payloads.d.ts.map +1 -0
  208. package/dist/tools/payloads.js +2 -0
  209. package/dist/tools/payloads.js.map +1 -0
  210. package/dist/tools/recovery.d.ts +31 -0
  211. package/dist/tools/recovery.d.ts.map +1 -0
  212. package/dist/tools/recovery.js +350 -0
  213. package/dist/tools/recovery.js.map +1 -0
  214. package/dist/tools/sleep.d.ts +10 -0
  215. package/dist/tools/sleep.d.ts.map +1 -0
  216. package/dist/tools/sleep.js +124 -0
  217. package/dist/tools/sleep.js.map +1 -0
  218. package/dist/tools/stress.d.ts +11 -0
  219. package/dist/tools/stress.d.ts.map +1 -0
  220. package/dist/tools/stress.js +108 -0
  221. package/dist/tools/stress.js.map +1 -0
  222. package/dist/tools/trainingInsights.d.ts +15 -0
  223. package/dist/tools/trainingInsights.d.ts.map +1 -0
  224. package/dist/tools/trainingInsights.js +79 -0
  225. package/dist/tools/trainingInsights.js.map +1 -0
  226. package/dist/tools/types.d.ts +12 -0
  227. package/dist/tools/types.d.ts.map +1 -0
  228. package/dist/tools/types.js +2 -0
  229. package/dist/tools/types.js.map +1 -0
  230. package/dist/tools/vo2Max.d.ts +11 -0
  231. package/dist/tools/vo2Max.d.ts.map +1 -0
  232. package/dist/tools/vo2Max.js +110 -0
  233. package/dist/tools/vo2Max.js.map +1 -0
  234. package/dist/tools/week.d.ts +12 -0
  235. package/dist/tools/week.d.ts.map +1 -0
  236. package/dist/tools/week.js +74 -0
  237. package/dist/tools/week.js.map +1 -0
  238. package/dist/usage.d.ts +104 -0
  239. package/dist/usage.d.ts.map +1 -0
  240. package/dist/usage.js +247 -0
  241. package/dist/usage.js.map +1 -0
  242. package/dist/utils/batch.d.ts +2 -0
  243. package/dist/utils/batch.d.ts.map +1 -0
  244. package/dist/utils/batch.js +18 -0
  245. package/dist/utils/batch.js.map +1 -0
  246. package/dist/utils/helpers.d.ts +32 -0
  247. package/dist/utils/helpers.d.ts.map +1 -0
  248. package/dist/utils/helpers.js +188 -0
  249. package/dist/utils/helpers.js.map +1 -0
  250. package/dist/utils/jsonFile.d.ts +19 -0
  251. package/dist/utils/jsonFile.d.ts.map +1 -0
  252. package/dist/utils/jsonFile.js +24 -0
  253. package/dist/utils/jsonFile.js.map +1 -0
  254. package/dist/utils/logger.d.ts +4 -0
  255. package/dist/utils/logger.d.ts.map +1 -0
  256. package/dist/utils/logger.js +97 -0
  257. package/dist/utils/logger.js.map +1 -0
  258. package/dist/utils/secretFile.d.ts +15 -0
  259. package/dist/utils/secretFile.d.ts.map +1 -0
  260. package/dist/utils/secretFile.js +47 -0
  261. package/dist/utils/secretFile.js.map +1 -0
  262. package/dist/utils/stdio.d.ts +10 -0
  263. package/dist/utils/stdio.d.ts.map +1 -0
  264. package/dist/utils/stdio.js +70 -0
  265. package/dist/utils/stdio.js.map +1 -0
  266. package/dist/version.d.ts +2 -0
  267. package/dist/version.d.ts.map +1 -0
  268. package/dist/version.js +7 -0
  269. package/dist/version.js.map +1 -0
  270. package/dist/watchApi.d.ts +264 -0
  271. package/dist/watchApi.d.ts.map +1 -0
  272. package/dist/watchApi.js +333 -0
  273. package/dist/watchApi.js.map +1 -0
  274. package/package.json +77 -0
@@ -0,0 +1,114 @@
1
+ import { DateTime } from "luxon";
2
+ import { getActivitiesBetween, getMetricSeries } from "../history/store.js";
3
+ import { detectHrvTrendBreak, detectLoadRatio, detectRestingHrElevation, detectSleepDebt, } from "./detectors.js";
4
+ // SECTION: Running the detectors
5
+ /** Below this, the store describes a fortnight's mood rather than a person. */
6
+ const READY_DAYS = 14;
7
+ /**
8
+ * How old the newest measurement may be before "nothing stands out" stops being
9
+ * an answer.
10
+ *
11
+ * Coverage counted total days and ignored recency, so a store holding 74 days
12
+ * that stopped three weeks ago reported `ready: true` and every detector then
13
+ * correctly found nothing -- because there was nothing recent to find. The app
14
+ * told the user, confidently, that nothing stood out. That is the exact failure
15
+ * the cold-start guard exists to prevent: an absence rendered as a clean bill of
16
+ * health. It just was not looking at the right absence.
17
+ *
18
+ * Three days of grace: Garmin finalises a sleep score hours after waking, and a
19
+ * watch that has not been synced since yesterday is normal.
20
+ */
21
+ const MAX_STALE_DAYS = 3;
22
+ /** Every metric a detector reads, for the coverage figure. */
23
+ const COVERAGE_KINDS = ["resting_hr", "sleep_seconds", "hrv_overnight"];
24
+ const SEVERITY_ORDER = {
25
+ warn: 0,
26
+ notice: 1,
27
+ info: 2,
28
+ };
29
+ export function describeFindingsCoverage(coverage) {
30
+ if (coverage.ready) {
31
+ return { state: "ready", detail: `${coverage.days} days of history`, fix: null };
32
+ }
33
+ // Depth is present and the record has simply stopped. Note this is tested
34
+ // before the day count: a store can be both short and stale, and "go and
35
+ // fetch" is the useful answer in that case too.
36
+ if (coverage.throughDate !== null && coverage.staleDays > MAX_STALE_DAYS) {
37
+ return {
38
+ state: "stale",
39
+ detail: `${coverage.days} days of history are stored, but the record stops at ` +
40
+ `${coverage.throughDate} — ${coverage.staleDays} days ago. Nothing is known about the days since. ` +
41
+ `The stored history is real and still worth reasoning about; what is missing is only the days after that date.`,
42
+ fix: "Run `trainbud backfill --days 20` to bring the record up to date.",
43
+ };
44
+ }
45
+ return {
46
+ state: "cold",
47
+ detail: `Still gathering data — ${coverage.days} of the ${READY_DAYS} days needed before anything can be compared against a baseline.`,
48
+ fix: "Run `trainbud backfill` to pull what Garmin already holds.",
49
+ };
50
+ }
51
+ /** The only function here that touches the store. */
52
+ export function buildDetectorInput(now = DateTime.local()) {
53
+ const startOfDay = now.startOf("day");
54
+ return {
55
+ now,
56
+ series: (kind, days) => {
57
+ const start = startOfDay.minus({ days }).toISODate() ?? "";
58
+ const end = startOfDay.toISODate() ?? "";
59
+ return getMetricSeries(kind, start, end);
60
+ },
61
+ activities: (days) => {
62
+ const start = startOfDay.minus({ days }).toISODate() ?? "";
63
+ const end = startOfDay.toISODate() ?? "";
64
+ return getActivitiesBetween(start, end);
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * Cold start is a first-class case, not an edge one. With no history every
70
+ * detector correctly returns null, and an empty array on its own is
71
+ * indistinguishable from a clean bill of health. `ready` is what lets a surface
72
+ * say "still gathering data" instead of implying everything is fine -- so
73
+ * surfaces read the flag, never the length of the array.
74
+ */
75
+ export function runDetectors(input = buildDetectorInput()) {
76
+ const series = COVERAGE_KINDS.map((kind) => input.series(kind, 365));
77
+ const days = Math.max(0, ...series.map((points) => points.length));
78
+ // The newest measurement of any kind, and how old it is. A detector compares
79
+ // a recent day against a baseline; with no recent day there is nothing to
80
+ // compare, and saying "nothing stands out" would be a claim about a day that
81
+ // was never recorded.
82
+ const throughDate = series
83
+ .map((points) => points.at(-1)?.date ?? null)
84
+ .filter((date) => date !== null)
85
+ .sort()
86
+ .at(-1) ?? null;
87
+ const today = input.now.startOf("day");
88
+ const staleDays = throughDate === null
89
+ ? Number.MAX_SAFE_INTEGER
90
+ : Math.max(0, Math.round(today.diff(DateTime.fromISO(throughDate).startOf("day"), "days").days));
91
+ const findings = [
92
+ detectRestingHrElevation(input),
93
+ detectHrvTrendBreak(input),
94
+ detectLoadRatio(input),
95
+ detectSleepDebt(input),
96
+ ].filter((finding) => finding !== null);
97
+ // Severity first, then a fixed detector order via the kind. The watch shows
98
+ // the first couple, and findings that shuffled between syncs would read as
99
+ // the data having changed when it has not.
100
+ findings.sort((left, right) => {
101
+ const bySeverity = SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity];
102
+ return bySeverity !== 0 ? bySeverity : left.kind.localeCompare(right.kind);
103
+ });
104
+ return {
105
+ findings,
106
+ coverage: {
107
+ days,
108
+ ready: days >= READY_DAYS && staleDays <= MAX_STALE_DAYS,
109
+ throughDate,
110
+ staleDays: throughDate === null ? 0 : staleDays,
111
+ },
112
+ };
113
+ }
114
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/detect/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ import type { DetectorInput } from "./findings.js";
2
+ export type SleepConsistency = "steady" | "variable" | "erratic" | "unknown";
3
+ export interface SleepQuality {
4
+ /** This person's own habitual nightly sleep, in hours. */
5
+ habitualHours: number | null;
6
+ /** Hours slept last night, or null if it was not recorded. */
7
+ lastNightHours: number | null;
8
+ /**
9
+ * Net hours under habitual across the last seven nights. Positive is a debt;
10
+ * negative means they slept more than usual. Null without a habitual figure.
11
+ */
12
+ debtHours: number | null;
13
+ /** Nights that actually carry a measurement in the debt window. */
14
+ nightsCounted: number;
15
+ /** Typical night-to-night swing, in minutes. */
16
+ variationMinutes: number | null;
17
+ consistency: SleepConsistency;
18
+ summary: string;
19
+ }
20
+ export declare function analyseSleep(input: DetectorInput): SleepQuality;
21
+ //# sourceMappingURL=sleepQuality.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sleepQuality.d.ts","sourceRoot":"","sources":["../../src/detect/sleepQuality.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AA0CnD,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAE7E,MAAM,WAAW,YAAY;IAC3B,0DAA0D;IAC1D,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,8DAA8D;IAC9D,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,mEAAmE;IACnE,aAAa,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,WAAW,EAAE,gBAAgB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;CACjB;AAuCD,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,YAAY,CAoH/D"}
@@ -0,0 +1,164 @@
1
+ import { DateTime } from "luxon";
2
+ import { median } from "./baseline.js";
3
+ // SECTION: Sleep debt and consistency
4
+ //
5
+ // The Sleep card shows last night. Last night is the least informative sleep
6
+ // number there is: everyone has a bad night, and a single figure cannot tell a
7
+ // late film from a fortnight of five-hour nights.
8
+ //
9
+ // Two things it cannot say and this can:
10
+ //
11
+ // * DEBT -- how far the last week sits under what this person actually
12
+ // sleeps. Against their own median, not against eight hours. Eight hours is
13
+ // a population figure and telling a habitual seven-hour sleeper they are an
14
+ // hour down every night of their life is how a health app becomes noise.
15
+ //
16
+ // * CONSISTENCY -- how much the nights vary. Regularity predicts how rested
17
+ // someone is well beyond duration, and it is invisible in any single night.
18
+ // Measured as median absolute deviation rather than standard deviation, for
19
+ // the same reason every baseline here does: one 11-hour recovery sleep
20
+ // after a race would inflate a standard deviation and make a metronomic
21
+ // sleeper look erratic.
22
+ //
23
+ // Neither number is a diagnosis and neither mentions one.
24
+ const NEED_WINDOW_DAYS = 28;
25
+ const DEBT_WINDOW_DAYS = 7;
26
+ const CONSISTENCY_WINDOW_DAYS = 14;
27
+ /** Below this the figures describe a fortnight's mood rather than a person. */
28
+ const MIN_NIGHTS_FOR_NEED = 14;
29
+ /**
30
+ * How old the newest row may be and still be called "last night".
31
+ *
32
+ * One day: a watch that has not been synced since this morning is ordinary. Two
33
+ * weeks is not, and the store's newest row was being reported as last night
34
+ * however old it was.
35
+ */
36
+ const LAST_NIGHT_GRACE_DAYS = 1;
37
+ const MIN_NIGHTS_FOR_CONSISTENCY = 7;
38
+ const SECONDS_PER_HOUR = 3600;
39
+ function round(value, places = 1) {
40
+ const factor = 10 ** places;
41
+ return Math.round(value * factor) / factor;
42
+ }
43
+ /**
44
+ * Bands are in minutes of typical swing. Half an hour either side of your own
45
+ * median is an ordinary life; two hours is a different sleep pattern every
46
+ * night, and that is worth naming even when the total is fine -- which is
47
+ * exactly the case a duration-only card cannot see.
48
+ */
49
+ function bandFor(variationMinutes) {
50
+ if (variationMinutes <= 30) {
51
+ return "steady";
52
+ }
53
+ return variationMinutes <= 60 ? "variable" : "erratic";
54
+ }
55
+ /**
56
+ * Nights inside the last `days` calendar days, newest last.
57
+ *
58
+ * NOT `slice(-days)`. Seven array positions are seven nights only when the
59
+ * store has no holes, and it routinely does -- a night gets a row only when the
60
+ * watch was worn. `split()` in detectors.ts carries a long comment about
61
+ * exactly this fault, written after a resting heart rate elevated in June was
62
+ * reported as "3 days running" in August. The same slice survived here, and on
63
+ * the live store, whose record ends 2026-08-21, this file reported that night
64
+ * as "last night" on 2026-09-03 and computed a debt over 08-15..08-21 while
65
+ * calling it "the last 7 nights".
66
+ */
67
+ function withinDays(points, days, now) {
68
+ const cutoff = now.startOf("day").minus({ days }).toISODate() ?? "";
69
+ return points.filter((point) => point.date > cutoff);
70
+ }
71
+ export function analyseSleep(input) {
72
+ const window = input.series("sleep_seconds", NEED_WINDOW_DAYS);
73
+ const hours = window.map((point) => point.value / SECONDS_PER_HOUR);
74
+ const empty = {
75
+ habitualHours: null,
76
+ lastNightHours: null,
77
+ debtHours: null,
78
+ nightsCounted: 0,
79
+ variationMinutes: null,
80
+ consistency: "unknown",
81
+ summary: "Not enough nights recorded yet to describe your sleep pattern.",
82
+ };
83
+ if (hours.length === 0) {
84
+ return empty;
85
+ }
86
+ // "Last night" is a claim about last night. The newest row in the store is
87
+ // only that if it is dated last night; grace of one day for a watch that has
88
+ // not synced yet, and nothing beyond.
89
+ const newest = window.at(-1);
90
+ const newestAge = newest === undefined
91
+ ? null
92
+ : Math.round(input.now.startOf("day").diff(DateTime.fromISO(newest.date).startOf("day"), "days").days);
93
+ const lastNightHours = newest !== undefined && newestAge !== null && newestAge <= LAST_NIGHT_GRACE_DAYS
94
+ ? round(newest.value / SECONDS_PER_HOUR)
95
+ : null;
96
+ if (hours.length < MIN_NIGHTS_FOR_NEED) {
97
+ return {
98
+ ...empty,
99
+ lastNightHours,
100
+ nightsCounted: hours.length,
101
+ summary: `Only ${hours.length} nights recorded so far; ${MIN_NIGHTS_FOR_NEED} are needed before a personal baseline means anything.`,
102
+ };
103
+ }
104
+ const habitual = median(hours);
105
+ if (habitual === null) {
106
+ return { ...empty, lastNightHours, nightsCounted: hours.length };
107
+ }
108
+ const debtNights = withinDays(window, DEBT_WINDOW_DAYS, input.now);
109
+ // A week with nothing recorded in it has no deficit; it has no week. Stating
110
+ // a debt of zero here would read as "you slept exactly your usual amount",
111
+ // which is the absence-rendered-as-a-measurement fault this file's own
112
+ // neighbours were fixed for.
113
+ if (debtNights.length === 0) {
114
+ return {
115
+ ...empty,
116
+ habitualHours: round(habitual),
117
+ lastNightHours,
118
+ summary: `No nights recorded in the last ${DEBT_WINDOW_DAYS} days, so there is nothing to compare against your usual ${round(habitual)}h. The record stops at ${newest?.date ?? "an earlier date"}.`,
119
+ };
120
+ }
121
+ const debtWindow = debtNights.map((point) => point.value / SECONDS_PER_HOUR);
122
+ const debt = debtWindow.reduce((total, night) => total + (habitual - night), 0);
123
+ const consistencyWindow = withinDays(window, CONSISTENCY_WINDOW_DAYS, input.now).map((point) => point.value / SECONDS_PER_HOUR);
124
+ let variationMinutes = null;
125
+ let consistency = "unknown";
126
+ if (consistencyWindow.length >= MIN_NIGHTS_FOR_CONSISTENCY) {
127
+ const centre = median(consistencyWindow);
128
+ if (centre !== null) {
129
+ const deviations = consistencyWindow.map((night) => Math.abs(night - centre));
130
+ const mad = median(deviations);
131
+ if (mad !== null) {
132
+ variationMinutes = Math.round(mad * 60);
133
+ consistency = bandFor(variationMinutes);
134
+ }
135
+ }
136
+ }
137
+ const habitualHours = round(habitual);
138
+ const debtHours = round(debt);
139
+ // Names the number of nights actually MEASURED, and the window they sit in,
140
+ // because those are two different numbers whenever the watch went unworn.
141
+ const covered = debtWindow.length === DEBT_WINDOW_DAYS
142
+ ? `the last ${DEBT_WINDOW_DAYS} nights`
143
+ : `${debtWindow.length} recorded nights in the last ${DEBT_WINDOW_DAYS} days`;
144
+ const debtSentence = debtHours >= 1
145
+ ? `You are ${debtHours}h under your own usual ${habitualHours}h across ${covered}.`
146
+ : debtHours <= -1
147
+ ? `You are ${round(-debtHours)}h above your own usual ${habitualHours}h across ${covered}.`
148
+ : `Your sleep across ${covered} is close to your usual ${habitualHours}h.`;
149
+ const consistencySentence = consistency === "unknown"
150
+ ? ""
151
+ : consistency === "steady"
152
+ ? ` Your nights are steady, within about ${variationMinutes} minutes of each other.`
153
+ : ` Your nights swing by about ${variationMinutes} minutes, which is ${consistency === "erratic" ? "a different pattern most nights" : "more variable than steady"}.`;
154
+ return {
155
+ habitualHours,
156
+ lastNightHours,
157
+ debtHours,
158
+ nightsCounted: debtWindow.length,
159
+ variationMinutes,
160
+ consistency,
161
+ summary: debtSentence + consistencySentence,
162
+ };
163
+ }
164
+ //# sourceMappingURL=sleepQuality.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sleepQuality.js","sourceRoot":"","sources":["../../src/detect/sleepQuality.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import type { MetricPoint, StoredActivity } from "../history/store.js";
2
+ export interface HrProfile {
3
+ restingHr: number;
4
+ maxHr: number;
5
+ }
6
+ /**
7
+ * `maxHr` is the highest daily maximum ever recorded, which is an *observed*
8
+ * maximum rather than a true one -- the user may simply never have gone that
9
+ * hard. It is the best available without asking their age, and it errs toward
10
+ * rating sessions harder rather than softer, which is the safer direction for a
11
+ * load warning.
12
+ */
13
+ export declare function estimateHrProfile(restingPoints: MetricPoint[], maxPoints: MetricPoint[]): HrProfile | null;
14
+ /**
15
+ * Null, never zero, when the session cannot be scored. A zero is a claim that
16
+ * the session was easy; null is the truth, which is that a strength session
17
+ * recorded without a heart rate says nothing about load either way.
18
+ */
19
+ export declare function trimpFor(activity: StoredActivity, profile: HrProfile): number | null;
20
+ /** Load per calendar day. Two sessions on one date sum into a single entry. */
21
+ export declare function dailyTrimp(activities: StoredActivity[], profile: HrProfile): Map<string, number>;
22
+ //# sourceMappingURL=trimp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trimp.d.ts","sourceRoot":"","sources":["../../src/detect/trimp.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAuBvE,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,aAAa,EAAE,WAAW,EAAE,EAC5B,SAAS,EAAE,WAAW,EAAE,GACvB,SAAS,GAAG,IAAI,CAoBlB;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,cAAc,EAAE,OAAO,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,CAqBpF;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CACxB,UAAU,EAAE,cAAc,EAAE,EAC5B,OAAO,EAAE,SAAS,GACjB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAarB"}
@@ -0,0 +1,77 @@
1
+ import { clamp } from "../utils/helpers.js";
2
+ // SECTION: Training load
3
+ //
4
+ // The activity payload this project fetches carries no load at all -- no
5
+ // activityTrainingLoad, no training effect, no zone time -- so Garmin's own
6
+ // number is not available, and where it does exist it is absent for many
7
+ // activity types. Banister TRIMP needs only duration and average heart rate,
8
+ // which every recorded activity has, and the definition is ours and testable
9
+ // rather than opaque and undocumented.
10
+ //
11
+ // HRr = (avgHr - restingHr) / (maxHr - restingHr) clamped to [0, 1]
12
+ // TRIMP = minutes x HRr x 0.64 x e^(1.92 x HRr)
13
+ //
14
+ // The exponential is what makes an hour hard worth more than two hours easy.
15
+ //
16
+ // This will not match anything Connect shows, because Connect is not computing
17
+ // TRIMP. Anything that surfaces the number says so.
18
+ const INTENSITY_COEFFICIENT = 0.64;
19
+ const INTENSITY_EXPONENT = 1.92;
20
+ /**
21
+ * `maxHr` is the highest daily maximum ever recorded, which is an *observed*
22
+ * maximum rather than a true one -- the user may simply never have gone that
23
+ * hard. It is the best available without asking their age, and it errs toward
24
+ * rating sessions harder rather than softer, which is the safer direction for a
25
+ * load warning.
26
+ */
27
+ export function estimateHrProfile(restingPoints, maxPoints) {
28
+ const resting = restingPoints
29
+ .map((point) => point.value)
30
+ .filter((value) => Number.isFinite(value) && value > 0);
31
+ const maxima = maxPoints
32
+ .map((point) => point.value)
33
+ .filter((value) => Number.isFinite(value) && value > 0);
34
+ if (resting.length === 0 || maxima.length === 0) {
35
+ return null;
36
+ }
37
+ const restingHr = Math.min(...resting);
38
+ const maxHr = Math.max(...maxima);
39
+ if (maxHr <= restingHr) {
40
+ return null;
41
+ }
42
+ return { restingHr, maxHr };
43
+ }
44
+ /**
45
+ * Null, never zero, when the session cannot be scored. A zero is a claim that
46
+ * the session was easy; null is the truth, which is that a strength session
47
+ * recorded without a heart rate says nothing about load either way.
48
+ */
49
+ export function trimpFor(activity, profile) {
50
+ const { avgHr, durationSeconds } = activity;
51
+ if (avgHr === null || durationSeconds === null) {
52
+ return null;
53
+ }
54
+ if (!Number.isFinite(avgHr) || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
55
+ return null;
56
+ }
57
+ const range = profile.maxHr - profile.restingHr;
58
+ if (range <= 0) {
59
+ return null;
60
+ }
61
+ const reserve = clamp((avgHr - profile.restingHr) / range, 0, 1);
62
+ const minutes = durationSeconds / 60;
63
+ return (minutes * reserve * INTENSITY_COEFFICIENT * Math.exp(INTENSITY_EXPONENT * reserve));
64
+ }
65
+ /** Load per calendar day. Two sessions on one date sum into a single entry. */
66
+ export function dailyTrimp(activities, profile) {
67
+ const byDay = new Map();
68
+ for (const activity of activities) {
69
+ const score = trimpFor(activity, profile);
70
+ if (score === null) {
71
+ continue;
72
+ }
73
+ byDay.set(activity.date, (byDay.get(activity.date) ?? 0) + score);
74
+ }
75
+ return byDay;
76
+ }
77
+ //# sourceMappingURL=trimp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trimp.js","sourceRoot":"","sources":["../../src/detect/trimp.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ import { type LoadForecast } from "./forecast.js";
2
+ import { type SleepQuality } from "./sleepQuality.js";
3
+ import type { DetectorInput } from "./findings.js";
4
+ export type Direction = "up" | "down" | "flat" | "unknown";
5
+ export interface WeekMetric {
6
+ key: string;
7
+ label: string;
8
+ unit: string;
9
+ current: number | null;
10
+ previous: number | null;
11
+ delta: number | null;
12
+ direction: Direction;
13
+ /** True when the change is large enough to be worth reading. */
14
+ notable: boolean;
15
+ }
16
+ export interface WeekReview {
17
+ start: string;
18
+ end: string;
19
+ /** False when there is not enough history for the comparison to mean anything. */
20
+ ready: boolean;
21
+ sessions: number;
22
+ previousSessions: number;
23
+ movingMinutes: number;
24
+ previousMovingMinutes: number;
25
+ metrics: WeekMetric[];
26
+ forecast: LoadForecast;
27
+ sleep: SleepQuality;
28
+ headline: string;
29
+ }
30
+ export declare function buildWeekReview(input: DetectorInput): WeekReview;
31
+ //# sourceMappingURL=week.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"week.d.ts","sourceRoot":"","sources":["../../src/detect/week.ts"],"names":[],"mappings":"AACA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAgCnD,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,SAAS,CAAC;IACrB,gEAAgE;IAChE,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,kFAAkF;IAClF,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,QAAQ,EAAE,YAAY,CAAC;IACvB,KAAK,EAAE,YAAY,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkFD,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,UAAU,CAoHhE"}
@@ -0,0 +1,184 @@
1
+ import { median } from "./baseline.js";
2
+ import { forecastLoad } from "./forecast.js";
3
+ import { analyseSleep } from "./sleepQuality.js";
4
+ import { dailyTrimp, estimateHrProfile } from "./trimp.js";
5
+ // SECTION: Weekly review
6
+ //
7
+ // Every other surface in this app answers "how am I today". None of them
8
+ // answers "how was the week", which is the unit training is actually planned
9
+ // in -- and it is the question the store has been able to answer since the
10
+ // memory layer landed without anything ever asking it.
11
+ //
12
+ // Each line is this week against last week, both measured from the same store,
13
+ // so the comparison is like for like. Where a metric is missing on either side
14
+ // the line says so rather than showing a delta against zero: an unworn watch is
15
+ // not a resting heart rate of nothing, and this project's bug history is full
16
+ // of absences that were rendered as measurements.
17
+ const WEEK_DAYS = 7;
18
+ /**
19
+ * How far back to ask for, to get exactly two seven-day weeks.
20
+ *
21
+ * `series(kind, n)` returns `today - n .. today` INCLUSIVE, so it spans n + 1
22
+ * calendar days. Asked for `WEEK_DAYS * 2` and split at `today - 7`, the halves
23
+ * came out 7 and 8: every wellness metric and the session count compared a
24
+ * seven-day week against an eight-day one, while the TRIMP load line beside
25
+ * them summed two explicit seven-day windows and was right. One card
26
+ * disagreeing with itself, and the wrong half is the one a reader quotes.
27
+ *
28
+ * Thirteen gives `today - 13 .. today`: fourteen days, seven each side.
29
+ */
30
+ const COMPARISON_DAYS = WEEK_DAYS * 2 - 1;
31
+ /** Two full weeks, because the review is a comparison and one week compares to nothing. */
32
+ const READY_DAYS = 14;
33
+ const SECONDS_PER_HOUR = 3600;
34
+ const SECONDS_PER_MINUTE = 60;
35
+ function round(value, places = 1) {
36
+ const factor = 10 ** places;
37
+ return Math.round(value * factor) / factor;
38
+ }
39
+ function directionFor(delta, threshold) {
40
+ if (delta === null) {
41
+ return "unknown";
42
+ }
43
+ if (Math.abs(delta) < threshold) {
44
+ return "flat";
45
+ }
46
+ return delta > 0 ? "up" : "down";
47
+ }
48
+ /**
49
+ * Splits a series into this week and the week before by date rather than by
50
+ * position. Position slicing silently misaligns the moment a day is missing --
51
+ * fourteen points is not necessarily fourteen consecutive days, and on this
52
+ * store it usually is not.
53
+ */
54
+ function weekHalves(input, kind) {
55
+ const today = input.now.startOf("day");
56
+ const cutoff = today.minus({ days: WEEK_DAYS }).toISODate() ?? "";
57
+ const points = input.series(kind, COMPARISON_DAYS);
58
+ const current = [];
59
+ const previous = [];
60
+ for (const point of points) {
61
+ if (point.date > cutoff) {
62
+ current.push(point.value);
63
+ }
64
+ else {
65
+ previous.push(point.value);
66
+ }
67
+ }
68
+ return { current, previous };
69
+ }
70
+ function metricFrom(key, label, unit, current, previous, options) {
71
+ const scale = options.scale ?? 1;
72
+ const places = options.places ?? 1;
73
+ const currentValue = median(current);
74
+ const previousValue = median(previous);
75
+ const scaled = currentValue === null ? null : round(currentValue / scale, places);
76
+ const scaledPrevious = previousValue === null ? null : round(previousValue / scale, places);
77
+ const delta = scaled === null || scaledPrevious === null ? null : round(scaled - scaledPrevious, places);
78
+ return {
79
+ key,
80
+ label,
81
+ unit,
82
+ current: scaled,
83
+ previous: scaledPrevious,
84
+ delta,
85
+ direction: directionFor(delta, options.notableDelta),
86
+ notable: delta !== null && Math.abs(delta) >= options.notableDelta,
87
+ };
88
+ }
89
+ export function buildWeekReview(input) {
90
+ const today = input.now.startOf("day");
91
+ const start = today.minus({ days: WEEK_DAYS - 1 }).toISODate() ?? "";
92
+ const end = today.toISODate() ?? "";
93
+ const cutoff = today.minus({ days: WEEK_DAYS }).toISODate() ?? "";
94
+ const activities = input.activities(COMPARISON_DAYS);
95
+ const thisWeek = activities.filter((activity) => activity.date > cutoff);
96
+ const lastWeek = activities.filter((activity) => activity.date <= cutoff);
97
+ const movingSeconds = (list) => list.reduce((total, activity) => total + (activity.durationSeconds ?? 0), 0);
98
+ const sleep = weekHalves(input, "sleep_seconds");
99
+ const restingHr = weekHalves(input, "resting_hr");
100
+ const hrv = weekHalves(input, "hrv_overnight");
101
+ const stress = weekHalves(input, "stress_avg");
102
+ const metrics = [
103
+ metricFrom("sleep", "Sleep", "h", sleep.current, sleep.previous, {
104
+ scale: SECONDS_PER_HOUR,
105
+ notableDelta: 0.5,
106
+ }),
107
+ metricFrom("resting_hr", "Resting HR", "bpm", restingHr.current, restingHr.previous, {
108
+ places: 0,
109
+ notableDelta: 2,
110
+ }),
111
+ metricFrom("hrv", "HRV", "ms", hrv.current, hrv.previous, {
112
+ places: 0,
113
+ notableDelta: 5,
114
+ }),
115
+ metricFrom("stress", "Stress", "", stress.current, stress.previous, {
116
+ places: 0,
117
+ notableDelta: 5,
118
+ }),
119
+ ];
120
+ // Load gets its own line: it is a sum over the week, not a typical day, so
121
+ // taking a median of it the way the wellness metrics are taken would be
122
+ // meaningless.
123
+ const profile = estimateHrProfile(input.series("resting_hr", WEEK_DAYS * 4), input.series("max_hr", WEEK_DAYS * 4));
124
+ if (profile) {
125
+ const byDay = dailyTrimp(activities, profile);
126
+ const sumWindow = (fromDaysAgo) => {
127
+ let total = 0;
128
+ for (let offset = fromDaysAgo; offset < fromDaysAgo + WEEK_DAYS; offset += 1) {
129
+ const date = today.minus({ days: offset }).toISODate();
130
+ if (date) {
131
+ total += byDay.get(date) ?? 0;
132
+ }
133
+ }
134
+ return total;
135
+ };
136
+ const currentLoad = Math.round(sumWindow(0));
137
+ const previousLoad = Math.round(sumWindow(WEEK_DAYS));
138
+ const delta = currentLoad - previousLoad;
139
+ metrics.unshift({
140
+ key: "load",
141
+ label: "Load",
142
+ unit: "TRIMP",
143
+ current: currentLoad,
144
+ previous: previousLoad,
145
+ delta,
146
+ direction: directionFor(delta, Math.max(20, previousLoad * 0.15)),
147
+ notable: Math.abs(delta) >= Math.max(20, previousLoad * 0.15),
148
+ });
149
+ }
150
+ // Deliberately NOT narrowed to COMPARISON_DAYS along with the windows above.
151
+ // `ready` needs READY_DAYS recorded days, and over a fourteen-day span that
152
+ // would mean fourteen out of fourteen -- no missing day allowed, on a store
153
+ // where a day only gets a row when the watch was worn. Tightening that is a
154
+ // change to what "enough history" means, not a fix to the seven-versus-eight
155
+ // comparison, and it belongs in its own change with its own argument.
156
+ const coverageDays = Math.max(input.series("resting_hr", WEEK_DAYS * 2).length, input.series("sleep_seconds", WEEK_DAYS * 2).length);
157
+ const ready = coverageDays >= READY_DAYS;
158
+ const forecast = forecastLoad(input);
159
+ const sleepQuality = analyseSleep(input);
160
+ const notable = metrics.filter((metric) => metric.notable);
161
+ const headline = !ready
162
+ ? `Only ${coverageDays} of the last 14 days are recorded, so this week cannot be compared to last week yet.`
163
+ : notable.length === 0
164
+ ? `${thisWeek.length} session${thisWeek.length === 1 ? "" : "s"} this week, and nothing moved much against last week.`
165
+ : `${thisWeek.length} session${thisWeek.length === 1 ? "" : "s"} this week. ` +
166
+ notable
167
+ .map((metric) => `${metric.label} ${metric.direction === "up" ? "up" : "down"} ${Math.abs(metric.delta ?? 0)}${metric.unit}`)
168
+ .join(", ") +
169
+ " versus last week.";
170
+ return {
171
+ start,
172
+ end,
173
+ ready,
174
+ sessions: thisWeek.length,
175
+ previousSessions: lastWeek.length,
176
+ movingMinutes: Math.round(movingSeconds(thisWeek) / SECONDS_PER_MINUTE),
177
+ previousMovingMinutes: Math.round(movingSeconds(lastWeek) / SECONDS_PER_MINUTE),
178
+ metrics,
179
+ forecast,
180
+ sleep: sleepQuality,
181
+ headline,
182
+ };
183
+ }
184
+ //# sourceMappingURL=week.js.map