ourpr-mcp-server 0.2.0 → 0.4.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/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # ourpr-mcp-server
2
2
 
3
3
  An MCP server that lets an AI agent read **your own** running history from
4
- [ourpr](https://ourpr.app). Ask about your training in plain language, in
5
- whatever agent you already use.
4
+ [ourpr](https://ourpr.app), and write one thing back. Ask about your
5
+ training in plain language, in whatever agent you already use.
6
6
 
7
- Read only. It cannot change a run, plan a week, or issue another credential.
7
+ Every tool reads. One tool writes, and only that one: `ourpr_plan_week` puts
8
+ a plan on a day still ahead. It cannot log a run, edit history, or issue
9
+ another credential, and it needs a token made with the write scope.
8
10
 
9
11
  ## Why it exists
10
12
 
@@ -15,40 +17,105 @@ credential you issued to yourself and can revoke.
15
17
 
16
18
  ## Setup
17
19
 
18
- **1. Make a token.** In ourpr, go to **Settings → Access tokens → New token**.
19
- Name it after the machine it will live on. Copy it — it is shown once and
20
- cannot be recovered. A token lasts 90 days.
20
+ **1. Make a token.** In ourpr, go to
21
+ **Profile → Settings → ourpr. mcp → New token**. Name it after the machine it
22
+ will live on, and choose **Write** if you want the agent to plan your week;
23
+ **Read** otherwise. Copy it — it is shown once and cannot be
24
+ recovered. A token lasts 90 days.
21
25
 
22
26
  **2. Point a client at it.** The package runs from npm; nothing to clone.
23
27
 
28
+ **Keep the token out of any file inside a project.** A project's `.mcp.json`,
29
+ `.cursor/mcp.json` or `.vscode/mcp.json` is often committed, and a committed
30
+ token can be read by anyone who can read the repository. Every setup below
31
+ keeps the token in a user-level place or in your system's secret store.
32
+
33
+ **Pin the version.** Each setup names an exact version, so a new release never
34
+ runs with your token until you choose it. Change the number to update.
35
+
24
36
  ### Claude Code
25
37
 
38
+ Read the token without echoing it, so it stays out of your shell history, then
39
+ add the server for your user, not for a project:
40
+
26
41
  ```bash
27
- claude mcp add ourpr --env OURPR_TOKEN=ourpr_pat_... -- npx -y ourpr-mcp-server
42
+ printf 'Token: '; read -rs OURPR_TOKEN; echo
43
+ claude mcp add --scope user --env OURPR_TOKEN="$OURPR_TOKEN" --transport stdio \
44
+ ourpr -- npx -y ourpr-mcp-server@0.4.0
45
+ unset OURPR_TOKEN
28
46
  ```
29
47
 
48
+ Claude Code keeps it in `~/.claude.json`, which belongs to your user.
49
+
30
50
  ### Claude Desktop
31
51
 
32
52
  Download `ourpr.mcpb` from the latest release and open it. Claude Desktop asks
33
- for the token and stores it as a secret.
53
+ for the token and keeps it in your system's secret store. This is the
54
+ recommended way.
34
55
 
35
- Or edit `claude_desktop_config.json`:
56
+ Or edit `claude_desktop_config.json`, which lives in your user folder, not in
57
+ a project. The token is then plain text in that file:
36
58
 
37
59
  ```json
38
60
  {
39
61
  "mcpServers": {
40
62
  "ourpr": {
41
63
  "command": "npx",
42
- "args": ["-y", "ourpr-mcp-server"],
64
+ "args": ["-y", "ourpr-mcp-server@0.4.0"],
43
65
  "env": { "OURPR_TOKEN": "ourpr_pat_..." }
44
66
  }
45
67
  }
46
68
  }
47
69
  ```
48
70
 
49
- ### Cursor, VS Code
71
+ ### VS Code
72
+
73
+ Run **MCP: Open User Configuration** and add the server there. VS Code asks
74
+ for the token once, masks it, and keeps it in its secret store:
75
+
76
+ ```json
77
+ {
78
+ "inputs": [
79
+ {
80
+ "type": "promptString",
81
+ "id": "ourpr-token",
82
+ "description": "ourpr personal access token",
83
+ "password": true
84
+ }
85
+ ],
86
+ "servers": {
87
+ "ourpr": {
88
+ "type": "stdio",
89
+ "command": "npx",
90
+ "args": ["-y", "ourpr-mcp-server@0.4.0"],
91
+ "env": { "OURPR_TOKEN": "${input:ourpr-token}" }
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ ### Cursor
98
+
99
+ Set `OURPR_TOKEN` in your shell profile or your system's secret manager, and
100
+ add the server to the global `~/.cursor/mcp.json`. The file holds only a
101
+ reference to the variable:
102
+
103
+ ```json
104
+ {
105
+ "mcpServers": {
106
+ "ourpr": {
107
+ "command": "npx",
108
+ "args": ["-y", "ourpr-mcp-server@0.4.0"],
109
+ "env": { "OURPR_TOKEN": "${env:OURPR_TOKEN}" }
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ ### If a token leaks
50
116
 
51
- `.cursor/mcp.json` or `.vscode/mcp.json`, same shape as above.
117
+ Revoke it in **Profile → Settings → ourpr. mcp**, then make a new one.
118
+ Revocation is immediate.
52
119
 
53
120
  ### From source
54
121
 
@@ -58,6 +125,12 @@ cd ourpr-mcp-server
58
125
  npm install && npm run build && npm test
59
126
  ```
60
127
 
128
+ ## Protocol
129
+
130
+ The server speaks MCP `2026-07-28`, and it still answers a client that opens
131
+ with the older `initialize` handshake. It is built on the official TypeScript
132
+ SDK v2 (`@modelcontextprotocol/server`), and the tests run each version.
133
+
61
134
  ## Environment
62
135
 
63
136
  | | |
@@ -132,6 +205,67 @@ done that resembles a race you are training for.
132
205
  "What have I run that's like Boston — 26 miles, 800 feet of climb?"
133
206
  ```
134
207
 
208
+ ### `ourpr_training_blocks`
209
+
210
+ Your goal race and its Block: race day, distance, goal time, the week of the
211
+ Block today falls in, and the miles for each week so far. A week that has not
212
+ begun shows no miles, not zero. Also the Blocks before your past races, each
213
+ with its result, its weeks and its peak week.
214
+
215
+ ```
216
+ "What week of my Dallas block am I in, and how is my mileage building?"
217
+ "How does this block compare with the one before my last marathon?"
218
+ ```
219
+
220
+ `past`: how many past Blocks, newest first. Three by default.
221
+
222
+ ### `ourpr_list_races`
223
+
224
+ Every race ourpr finds in your history, tune-ups included, through the same
225
+ detector the app's Blocks use. Each row carries the run id for
226
+ `ourpr_get_run`. The answer also names the fastest result at 5K, 10K, half
227
+ marathon and marathon.
228
+
229
+ ```
230
+ "What is my half marathon PR?"
231
+ "List every marathon I have run, with the times"
232
+ ```
233
+
234
+ `distance`, `limit`.
235
+
236
+ ### `ourpr_list_plans`
237
+
238
+ The plans already on your week between two dates, two weeks from today by
239
+ default: the day, the name, the miles or time, the tag, your note, whether a
240
+ run you logged fulfilled it, and whether ourpr create wrote it. An agent reads
241
+ this before `ourpr_plan_week`, so a new plan does not land on a day that
242
+ already holds one.
243
+
244
+ ```
245
+ "What do I have planned for the next two weeks?"
246
+ "Did I do the runs I planned last week?"
247
+ ```
248
+
249
+ `start_date`, `end_date` (YYYY-MM-DD).
250
+
251
+ ### `ourpr_plan_week`
252
+
253
+ The one write. One planned run, or a week of them, onto days still ahead.
254
+ Each lands on the runner's week as a plan they can see, edit and remove; the
255
+ day sheet says it came from ourpr create. It never logs a run.
256
+
257
+ ```
258
+ "Put a 6 mile easy run on Tuesday and 14 long on Saturday"
259
+ "Write me next week: three easy days, one workout, one long run"
260
+ ```
261
+
262
+ `plans`, one to fourteen, each with `date` (YYYY-MM-DD, after today) and any
263
+ of `miles`, `minutes`, `name`, `note`, `tag` (easy, workout, race), `is_long`.
264
+
265
+ Needs a token made with the **Write** scope, and ourpr create on the account.
266
+ A **Read** token, or an account without create, is refused before anything is
267
+ written. Thirty plans a day.
268
+
135
269
  ## How the tool set was chosen
136
270
 
137
271
  By an evaluation, not by listing the API.
@@ -166,15 +300,19 @@ numbers the agent would only reduce anyway.
166
300
 
167
301
  ## Security
168
302
 
169
- The token grants read access to your own activity data and nothing else. It
170
- cannot write, cannot issue another token, cannot revoke your existing ones, and
171
- cannot widen its own scope.
303
+ The token grants read access to your own runs, your goal race and your plans.
304
+ On ourpr, every query a token makes is scoped to its owner by construction, and
305
+ a test refuses a token route that could reach past it. A token made with the
306
+ write scope, with ourpr create, may also put plans on your own week through
307
+ one route, and nothing else. No token can log a run, issue another token, revoke
308
+ your existing ones, or widen its own scope.
172
309
 
173
- Each token may make 60 reads a minute. ourpr answers 429 past that, with
174
- `RateLimit` and `Retry-After` headers, and the tool says how long to wait.
310
+ Each token may make 60 reads a minute, and a runner may write 30 plans a day.
311
+ ourpr answers 429 past either, with `RateLimit` and `Retry-After` headers, and
312
+ the tool says how long to wait.
175
313
 
176
- Revoke any token at any time in **Settings → Access tokens**. Revocation is
177
- immediate and permanent — a revoked token can never be restored.
314
+ Revoke any token at any time in **Profile → Settings → ourpr. mcp**.
315
+ Revocation is immediate and permanent — a revoked token can never be restored.
178
316
 
179
317
  ### Tool results are untrusted content
180
318
 
@@ -241,6 +379,18 @@ bundle's entry point; `npx @anthropic-ai/mcpb pack` builds `ourpr.mcpb` for
241
379
  the release. `server.json` registers the package in the MCP Registry with
242
380
  `mcp-publisher publish`.
243
381
 
382
+ Trusted publishing is the only way in. The package's npm settings require
383
+ two-factor authentication and disallow tokens, so no stolen npm token can
384
+ publish a version, and the OIDC workflow still can. `npm-shrinkwrap.json` ships
385
+ in the package, so every install resolves the same dependency tree that the
386
+ release tested. Run `npm install` and commit the shrinkwrap after any
387
+ dependency change.
388
+
389
+ A release changes the version in five places: `package.json`,
390
+ `npm-shrinkwrap.json` (through `npm install`), `manifest.json`, `server.json`
391
+ and the `McpServer` in `src/index.ts`. Every setup command in this README pins
392
+ the version, and so does `AGENT_VERSION` in ourpr's `lib/agent-connect.ts`.
393
+
244
394
  ## License
245
395
 
246
396
  MIT
package/build/api.js CHANGED
@@ -1,42 +1,12 @@
1
- // The ourpr API, as this server talks to it.
2
- //
3
- // AUTHENTICATION IS AN ENVIRONMENT VARIABLE, NOT A TOOL PARAMETER, and that is
4
- // a deliberate difference from revenuecat-charts-mcp, which takes `api_key` on
5
- // every call. RevenueCat issues one key per project and a person may hold
6
- // several, so asking per call is right there. An ourpr token belongs to ONE
7
- // runner and never changes between calls, so a parameter would put a live
8
- // credential into every tool call the agent makes — into its context, its
9
- // transcript, and any log of either. The env var keeps it out of all three.
10
- //
11
- // NOTHING THIS SERVER PRINTS MAY CARRY A CONFIGURED VALUE. `OURPR_TOKEN` is
12
- // never printed anywhere, and `OURPR_API_URL` is reduced to scheme and host
13
- // before it reaches an error — see `safeOrigin`. An error message travels into
14
- // the agent's context and its transcript, so printing there is publishing.
15
- //
16
- // NOTHING HERE MAY WRITE TO STDOUT. On a stdio server stdout IS the protocol
17
- // stream, and one stray `console.log` corrupts it. Diagnostics go to stderr.
1
+ // The token is an environment variable, never a tool parameter, so no call carries it.
18
2
  import { cell } from "./safe.js";
19
3
  const TOKEN = process.env.OURPR_TOKEN ?? "";
20
4
  const BASE = (process.env.OURPR_API_URL ?? "https://ourpr.onrender.com/api").replace(/\/$/, "");
21
- // A bearer token over plain http travels in the clear. Warn once rather than
22
- // refuse: localhost is how this server runs against a dev backend.
5
+ // A token over plain http travels in the clear; localhost is the dev backend.
23
6
  if (BASE.startsWith("http://") && !/^http:\/\/(localhost|127\.0\.0\.1)[:/]/.test(BASE)) {
24
7
  console.error("[ourpr-mcp-server] OURPR_API_URL is http, not https - the token is not encrypted in transit");
25
8
  }
26
- /**
27
- * The configured host, with everything else removed.
28
- *
29
- * NO ENVIRONMENT VALUE IS ECHOED WHOLE. An error goes straight into the
30
- * agent's context and its transcript, so anything printed there is published.
31
- * `OURPR_API_URL` is usually just a hostname — and it is a URL, so it CAN
32
- * carry `user:password@` before the host, which a self-hosted or staging
33
- * instance behind basic auth plausibly would. Dropping userinfo, path, query
34
- * and fragment leaves the one part that helps a person debug and none of the
35
- * part that must not travel.
36
- *
37
- * An unparseable value yields a placeholder rather than the raw string,
38
- * because "unparseable" is exactly the case where the string is unexpected.
39
- */
9
+ /** The configured host only; userinfo, path and query never reach an error. */
40
10
  function safeOrigin() {
41
11
  try {
42
12
  const url = new URL(BASE);
@@ -46,19 +16,9 @@ function safeOrigin() {
46
16
  return "the configured host";
47
17
  }
48
18
  }
49
- /**
50
- * Why a request failed, WITHOUT the message.
51
- *
52
- * A Node fetch failure carries the full request URL in `cause`, and a timeout
53
- * message can too. The code — ECONNREFUSED, ENOTFOUND, TimeoutError — says
54
- * everything a person needs and carries nothing they did not choose to
55
- * publish.
56
- */
19
+ /** The error code, never the message, which can carry the request URL. */
57
20
  function reason(err) {
58
- // WALK THE CAUSE CHAIN. Node wraps a connection failure as
59
- // `TypeError: fetch failed` with the useful code — ECONNREFUSED, ENOTFOUND —
60
- // one or two levels down in `cause`. Reading only the top gave "TypeError",
61
- // which tells a person nothing they can act on.
21
+ // Node puts the useful code one or two levels down in cause.
62
22
  let node = err;
63
23
  for (let depth = 0; node && depth < 4; depth++) {
64
24
  const code = node.code;
@@ -66,10 +26,7 @@ function reason(err) {
66
26
  return code;
67
27
  node = node.cause;
68
28
  }
69
- // NO CODE — a URL that will not parse ("bad port") reaches here, and its
70
- // MESSAGE is the only useful thing. Passed on ONLY when it cannot be
71
- // carrying configured values: an "@" means userinfo, and the raw base or the
72
- // token must never appear. Fails closed to the error name.
29
+ // A message passes only when it cannot carry a configured value.
73
30
  const deepest = deepestMessage(err);
74
31
  const safe = deepest &&
75
32
  !deepest.includes("@") &&
@@ -98,15 +55,18 @@ export class ApiError extends Error {
98
55
  function guidance(status, detail, retryAfterS) {
99
56
  if (status === 429) {
100
57
  const wait = /^\d+$/.test(retryAfterS ?? "") ? `${retryAfterS} seconds` : "a minute";
101
- return `ourpr allows 60 reads a minute for each token. Wait ${wait}, then ask again.`;
58
+ return ("ourpr allows 60 reads a minute for each token and 30 plans a day. " +
59
+ `Wait ${wait}, then ask again.`);
102
60
  }
103
61
  if (status === 401) {
104
62
  return ("ourpr rejected the token. It may be revoked, expired (a token lasts 90 " +
105
63
  "days), or OURPR_TOKEN may be unset. Make a new one in ourpr under " +
106
- "Settings, Access tokens.");
64
+ "Profile, Settings, ourpr. mcp.");
65
+ }
66
+ if (status === 403) {
67
+ return ("ourpr refused the write. The token needs the write scope, chosen when " +
68
+ "it is made, and ourpr create behind it.");
107
69
  }
108
- if (status === 403)
109
- return "That token can read only. This action needs a write scope.";
110
70
  if (status === 404)
111
71
  return "ourpr has no such run. Check the id against ourpr_list_runs.";
112
72
  if (status === 422)
@@ -115,33 +75,27 @@ function guidance(status, detail, retryAfterS) {
115
75
  return "ourpr had an error. Try again in a moment.";
116
76
  return detail || `ourpr answered ${status}.`;
117
77
  }
118
- /**
119
- * One line per call, on stderr.
120
- *
121
- * The 2026 guidance asks a server to log tool invocations, downstream calls,
122
- * errors and denials, so an operator can see what an agent did on their
123
- * behalf. THE PATH IS LOGGED AND THE TOKEN NEVER IS — a log that carries the
124
- * credential is a second copy of it, in a file nobody is guarding.
125
- */
78
+ // The path is logged and the token never is.
126
79
  function audit(path, outcome, ms) {
127
80
  console.error(`[ourpr-mcp-server] ${outcome} ${path.split("?")[0]} ${ms}ms`);
128
81
  }
129
- export async function get(path) {
82
+ async function request(method, path, payload) {
130
83
  const started = Date.now();
131
84
  if (!TOKEN) {
132
- throw new ApiError("OURPR_TOKEN is not set. Create a token in ourpr under Settings, " +
133
- "Access tokens, then put it in this server's environment.");
85
+ throw new ApiError("OURPR_TOKEN is not set. Create a token in ourpr under Profile, " +
86
+ "Settings, ourpr. mcp, then put it in this server's environment.");
134
87
  }
135
88
  let response;
136
89
  try {
137
90
  response = await fetch(`${BASE}${path}`, {
138
- headers: { Authorization: `Bearer ${TOKEN}` },
91
+ method,
92
+ headers: {
93
+ Authorization: `Bearer ${TOKEN}`,
94
+ ...(payload === undefined ? {} : { "Content-Type": "application/json" }),
95
+ },
96
+ body: payload === undefined ? undefined : JSON.stringify(payload),
139
97
  signal: AbortSignal.timeout(60_000),
140
- // DO NOT FOLLOW REDIRECTS WITH A CREDENTIAL ATTACHED. Node strips the
141
- // Authorization header across origins, so this is belt rather than
142
- // braces — but a redirect from ourpr's own host is not something this
143
- // server should follow silently either, because the answer would then
144
- // come from somewhere the operator did not configure.
98
+ // A request that carries the token never follows a redirect.
145
99
  redirect: "manual",
146
100
  });
147
101
  }
@@ -149,10 +103,7 @@ export async function get(path) {
149
103
  throw new ApiError(`Could not reach ourpr at ${safeOrigin()} (${reason(err)}). Check ` +
150
104
  "OURPR_API_URL and the network.");
151
105
  }
152
- // OUTSIDE THE TRY, deliberately. Thrown inside it, this was caught by the
153
- // network handler two lines up and re-reported as "could not reach ourpr" —
154
- // an error that names the wrong cause is worse than the status code it
155
- // replaced.
106
+ // Outside the try, so a redirect is not reported as a network failure.
156
107
  if (response.status >= 300 && response.status < 400) {
157
108
  audit(path, `${response.status}-redirect`, Date.now() - started);
158
109
  throw new ApiError("ourpr redirected the request, and this server does not follow " +
@@ -162,13 +113,11 @@ export async function get(path) {
162
113
  if (!response.ok) {
163
114
  let detail = "";
164
115
  try {
165
- // CLEANED LIKE ANY OTHER REMOTE TEXT. Today `detail` is written by the
166
- // backend, but a future backend change could derive it from runner
167
- // text, and this is the one place it enters an error message.
116
+ // detail is remote text and is cleaned like any other.
168
117
  detail = cell((await response.json()).detail, 200);
169
118
  }
170
119
  catch {
171
- // A non-JSON error body. The status still carries the meaning.
120
+ // The status carries the meaning without a body.
172
121
  }
173
122
  audit(path, `${response.status}`, Date.now() - started);
174
123
  throw new ApiError(guidance(response.status, detail, response.headers.get("retry-after") ?? undefined));
@@ -178,9 +127,7 @@ export async function get(path) {
178
127
  body = (await response.json());
179
128
  }
180
129
  catch {
181
- // A 200 whose body is not JSON - a proxy page, a captive portal. The
182
- // parse error's message can carry a fragment of that body, so the error
183
- // is replaced rather than passed on.
130
+ // A non-JSON body can leak into the parse error, so the error is replaced.
184
131
  audit(path, "200-not-json", Date.now() - started);
185
132
  throw new ApiError(`ourpr at ${safeOrigin()} answered 200 with a body that is not JSON. ` +
186
133
  "Check OURPR_API_URL - it should be the API base, ending in /api.");
@@ -188,19 +135,11 @@ export async function get(path) {
188
135
  audit(path, "200", Date.now() - started);
189
136
  return body;
190
137
  }
191
- // ─── Units ──────────────────────────────────────────────────────────────────
192
- //
193
- // The API speaks metres and stores pace as a string, "7:43". An agent reads
194
- // miles and reasons about pace as text. Convert once, here, so no tool does it
195
- // twice and no two tools do it differently.
196
- /**
197
- * An id on its way into a URL path.
198
- *
199
- * Ids arrive from the agent, and an agent can be steered by text inside a
200
- * tool result. Unencoded, a crafted id could append query parameters or move
201
- * the request to a sibling path - fetch normalises "../" before sending.
202
- * Encoding makes the id one path segment and nothing more.
203
- */
138
+ export const get = (path) => request("GET", path);
139
+ /** The one write. The route it reaches needs the write scope and ourpr create. */
140
+ export const post = (path, body) => request("POST", path, body);
141
+ // Units: the API speaks metres and an agent reads miles. Convert once, here.
142
+ /** Encoded, so a crafted id stays one path segment. */
204
143
  export const pathId = (value) => encodeURIComponent(value);
205
144
  export const METERS_PER_MILE = 1609.344;
206
145
  export const miles = (meters) => meters == null ? null : Math.round((meters / METERS_PER_MILE) * 100) / 100;
@@ -213,16 +152,41 @@ export function duration(seconds) {
213
152
  const m = Math.round((seconds % 3600) / 60);
214
153
  return h ? `${h}h ${m}m` : `${m}m`;
215
154
  }
155
+ /** "3:05:12" or "21:40". A race result reads to the second. */
156
+ export function clock(seconds) {
157
+ if (!seconds)
158
+ return null;
159
+ const whole = Math.round(seconds);
160
+ const h = Math.floor(whole / 3600);
161
+ const m = Math.floor((whole % 3600) / 60);
162
+ const s = String(whole % 60).padStart(2, "0");
163
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
164
+ }
216
165
  /** A day key, YYYY-MM-DD, out of whatever the API stored. */
217
166
  export const day = (iso) => (iso ?? "").slice(0, 10);
218
- /**
219
- * A calendar date to the Unix second the API wants.
220
- *
221
- * TOOLS TAKE DATES AND THE API TAKES TIMESTAMPS. An agent is asked "what did I
222
- * run in July", not "what did I run between 1751328000 and 1754006400", and
223
- * making it do that arithmetic is a step where it can quietly be wrong.
224
- * `end` pushes to the end of the day so a single-day window holds that day.
225
- */
167
+ /** Today on this machine's calendar, which is the runner's own. */
168
+ export function localDay(now) {
169
+ const m = String(now.getMonth() + 1).padStart(2, "0");
170
+ const d = String(now.getDate()).padStart(2, "0");
171
+ return `${now.getFullYear()}-${m}-${d}`;
172
+ }
173
+ // Day keys are walked as UTC dates, so a clock change never moves a day.
174
+ const DAY_MS = 86_400_000;
175
+ const dayMs = (key) => Date.parse(`${key}T00:00:00Z`);
176
+ export function addDays(key, days) {
177
+ const ms = dayMs(key);
178
+ if (Number.isNaN(ms))
179
+ throw new ApiError(`"${key}" is not a date. Use YYYY-MM-DD.`);
180
+ return new Date(ms + days * DAY_MS).toISOString().slice(0, 10);
181
+ }
182
+ /** Which week of a Block `today` falls in, or 0 before week 1's Monday. */
183
+ export function weekOf(start, weeks, today) {
184
+ const walked = Math.floor((dayMs(today) - dayMs(start)) / DAY_MS);
185
+ if (Number.isNaN(walked) || walked < 0)
186
+ return 0;
187
+ return Math.min(weeks, Math.floor(walked / 7) + 1);
188
+ }
189
+ /** A calendar date to a Unix second; "end" reaches the end of the day. */
226
190
  export function stamp(date, edge) {
227
191
  const time = edge === "start" ? "T00:00:00Z" : "T23:59:59Z";
228
192
  const ms = Date.parse(`${date}${time}`);