ccdeck 3.36.9 → 3.37.1

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 (75) hide show
  1. package/README.md +15 -7
  2. package/bin/agent-dag.js +19 -6
  3. package/bin/cli/help.js +1 -0
  4. package/bin/cli/login-item.js +44 -20
  5. package/bin/cli/one-shot.js +15 -5
  6. package/bin/cli/screen.js +10 -5
  7. package/bin/cli/startup.js +6 -2
  8. package/bin/cli/uninstall.js +41 -5
  9. package/bin/deck.js +50 -8
  10. package/dist/web/assets/BrowserWatchModal-njN0zg93.js +1 -0
  11. package/dist/web/assets/UsageHistoryModal-DYSZZZEz.js +1 -0
  12. package/dist/web/assets/index-B0SOa-XB.css +1 -0
  13. package/dist/web/assets/index-DDxnWaTa.js +306 -0
  14. package/dist/web/index.html +2 -2
  15. package/hook/hook.js +80 -9
  16. package/package.json +1 -1
  17. package/release-notes.json +8 -1
  18. package/src/server/account-origins.mjs +8 -1
  19. package/src/server/account-projects.mjs +34 -9
  20. package/src/server/activation.mjs +39 -10
  21. package/src/server/app-host.mjs +7 -2
  22. package/src/server/args.mjs +3 -0
  23. package/src/server/browser-watch-routes.mjs +23 -16
  24. package/src/server/browser-watch.mjs +9 -3
  25. package/src/server/claude-accounts.mjs +27 -7
  26. package/src/server/claude-verdicts.mjs +12 -1
  27. package/src/server/cli-versions.mjs +6 -4
  28. package/src/server/codex-auth.mjs +29 -0
  29. package/src/server/codex-quota.mjs +57 -7
  30. package/src/server/deck-home.mjs +23 -1
  31. package/src/server/deck-prefs.mjs +70 -7
  32. package/src/server/depth-facts.mjs +178 -0
  33. package/src/server/detach.mjs +61 -33
  34. package/src/server/event-log.mjs +15 -0
  35. package/src/server/event-pipeline.mjs +5 -2
  36. package/src/server/event-routes.mjs +22 -0
  37. package/src/server/feedback-form.mjs +36 -13
  38. package/src/server/index.mjs +6 -2
  39. package/src/server/install-layout.mjs +35 -0
  40. package/src/server/install-ref.mjs +39 -0
  41. package/src/server/installer.mjs +20 -2
  42. package/src/server/lan-beacon.mjs +18 -3
  43. package/src/server/lan-deck.mjs +7 -1
  44. package/src/server/lan-engine.mjs +40 -21
  45. package/src/server/lan-manifest.mjs +9 -6
  46. package/src/server/lan-requests.mjs +18 -2
  47. package/src/server/lan-serve.mjs +4 -2
  48. package/src/server/lan-sync.mjs +97 -6
  49. package/src/server/lifecycle.mjs +18 -2
  50. package/src/server/log-replay.mjs +9 -3
  51. package/src/server/login-service.mjs +95 -12
  52. package/src/server/npm-upgrade.mjs +12 -4
  53. package/src/server/pinned-build.mjs +3 -0
  54. package/src/server/prefs-routes.mjs +5 -8
  55. package/src/server/prefs-state.mjs +9 -1
  56. package/src/server/quota-oauth.mjs +88 -13
  57. package/src/server/quota-shape.mjs +28 -0
  58. package/src/server/quota.mjs +68 -17
  59. package/src/server/rating.mjs +87 -0
  60. package/src/server/reports-routes.mjs +21 -2
  61. package/src/server/reports.mjs +672 -64
  62. package/src/server/ring-bounds.mjs +11 -0
  63. package/src/server/running-deck.mjs +48 -0
  64. package/src/server/self-update.mjs +1 -1
  65. package/src/server/session-enrichment.mjs +14 -6
  66. package/src/server/stable-node.mjs +68 -0
  67. package/src/server/static-serve.mjs +5 -0
  68. package/src/server/supervisor.mjs +21 -15
  69. package/src/server/tailscale.mjs +8 -4
  70. package/src/server/transcript-scan.mjs +7 -4
  71. package/src/server/usage-day.mjs +76 -11
  72. package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
  74. package/dist/web/assets/index-BkXTN4dZ.css +0 -1
  75. package/dist/web/assets/index-tVBMXvDv.js +0 -306
@@ -9,7 +9,8 @@
9
9
  // self-poll floor; this module knows only how, and when a 429 says to stop.
10
10
  import { claudeConfigDir } from "./claude-dir.mjs";
11
11
  import { readFile } from "node:fs/promises";
12
- import { join } from "node:path";
12
+ import { createHash } from "node:crypto";
13
+ import { join, win32 as winPath } from "node:path";
13
14
  import { mapOAuthUsage } from "./quota-shape.mjs";
14
15
 
15
16
  export const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
@@ -100,6 +101,23 @@ export async function readOAuthToken() {
100
101
  }
101
102
  }
102
103
 
104
+ /**
105
+ * Which token the credentials file holds, as a hash, or null when it holds
106
+ * none — expired or not, because this is a question of whose it is rather
107
+ * than whether it still works. The token itself is not kept.
108
+ *
109
+ * For quota.mjs, which stamps a reading with it so a held reading is not
110
+ * served after Claude Code has been signed in as somebody else.
111
+ */
112
+ export async function credentialFingerprint() {
113
+ try {
114
+ const token = JSON.parse(await readFile(credentialsPath(), "utf8"))?.claudeAiOauth?.accessToken;
115
+ return typeof token === "string" && token ? createHash("sha256").update(token).digest("hex") : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
103
121
  /**
104
122
  * A cooldown from a `retry-after`, kept inside limits the deck can live with.
105
123
  *
@@ -136,23 +154,80 @@ export function cooldownFromHeader(raw, fallbackMs, minMs = 30_000, maxMs = 3600
136
154
  * answers this properly, with `api_key_mode` as its own reason and its own
137
155
  * sentence.
138
156
  *
139
- * Cheap and synchronous: environment first, because a machine configured for
140
- * Bedrock or Vertex says so there, then the presence of the OAuth block in the
141
- * credentials file. `readOAuthToken` above answers a different question — it
142
- * also rejects an EXPIRED token, and an expired subscription is still a
143
- * subscription.
157
+ * Cheap: configuration first, because a machine set up for Bedrock or Vertex
158
+ * says so there, then the presence of the OAuth block in the credentials file.
159
+ * `readOAuthToken` above answers a different question — it also rejects an
160
+ * EXPIRED token, and an expired subscription is still a subscription.
161
+ *
162
+ * THE CONFIGURATION IS CLAUDE CODE'S, not only this process's environment. The
163
+ * documented place for it is the `env` block of Claude Code's settings — user
164
+ * or managed — an API key can come from an `apiKeyHelper` there, and a deck
165
+ * started by the desktop app or a login item never sees the shell's exports at
166
+ * all. Reading process.env alone brought the two zero bars back on each of
167
+ * those machines. Values are read the way Claude Code reads them: "true",
168
+ * "yes" and "on" switch a provider on as well as "1".
169
+ *
170
+ * @param files Claude Code's settings files to read; claudeSettingsFiles() by
171
+ * default, and a parameter so a test can name its own.
144
172
  */
145
- export async function hasSubscriptionCredential(env = process.env) {
146
- if (env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_VERTEX === "1") return false;
173
+ export async function hasSubscriptionCredential(env = process.env, files = claudeSettingsFiles()) {
174
+ const { cloud, apiKey } = await claudeSignIn(env, files);
175
+ if (cloud) return false;
147
176
  try {
148
177
  const raw = await readFile(credentialsPath(), "utf8");
149
178
  if (JSON.parse(raw)?.claudeAiOauth?.accessToken) return true;
150
179
  } catch { /* absent or unreadable, decided below */ }
151
- // A key in the environment and no OAuth block beside it is the API-key
152
- // install. Without either, this deck simply has not been signed in yet, and
153
- // "sign in" is the right thing to say — which is the `waiting` branch, not
154
- // this one.
155
- return !(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN);
180
+ // A key — in an environment, or from a helper — and no OAuth block beside
181
+ // it is the API-key install. Without any, this deck simply has not been
182
+ // signed in yet, and "sign in" is the right thing to say — which is the
183
+ // `waiting` branch, not this one.
184
+ return !apiKey;
185
+ }
186
+
187
+ /**
188
+ * What Claude Code's configuration says about how it signs in, from `env` and
189
+ * the `env` blocks of its settings files, read as hasSubscriptionCredential
190
+ * explains: `cloud` when Bedrock or Vertex is switched on, `apiKey` when a key
191
+ * is set or an `apiKeyHelper` supplies one. The OAuth block in the credentials
192
+ * file is each caller's own question — it outranks a key, and only a cloud
193
+ * provider outranks it. Exported for the deck's own report of its plan
194
+ * (depth-facts.mjs), which has to answer the way the quota panel does.
195
+ */
196
+ export async function claudeSignIn(env = process.env, files = claudeSettingsFiles()) {
197
+ const settings = await readSettings(files);
198
+ const envs = [env, ...settings.map(s => (s.env && typeof s.env === "object" ? s.env : {}))];
199
+ return {
200
+ cloud: envs.some(e => isOn(e.CLAUDE_CODE_USE_BEDROCK) || isOn(e.CLAUDE_CODE_USE_VERTEX)),
201
+ apiKey: settings.some(s => typeof s.apiKeyHelper === "string" && s.apiKeyHelper.trim())
202
+ || envs.some(e => e.ANTHROPIC_API_KEY || e.ANTHROPIC_AUTH_TOKEN),
203
+ };
204
+ }
205
+
206
+ /** On, as Claude Code reads a switch in its environment. */
207
+ const isOn = (v) => /^(1|true|yes|on)$/i.test(String(v ?? "").trim());
208
+
209
+ /**
210
+ * The settings files in which Claude Code can be told how to sign in: the
211
+ * user's, in its config dir, and the managed one an administrator deploys.
212
+ * Project settings are left out — a deck watches every project at once, and
213
+ * no one project's file speaks for the machine.
214
+ */
215
+ export function claudeSettingsFiles(platform = process.platform, env = process.env) {
216
+ const managed = platform === "darwin" ? "/Library/Application Support/ClaudeCode/managed-settings.json"
217
+ : platform === "win32" ? winPath.join(env.ProgramFiles || "C:\\Program Files", "ClaudeCode", "managed-settings.json")
218
+ : "/etc/claude-code/managed-settings.json";
219
+ return [join(claudeConfigDir(env), "settings.json"), managed];
220
+ }
221
+
222
+ /** Each of `files` that parses as an object; a missing or broken one says nothing. */
223
+ async function readSettings(files) {
224
+ const read = await Promise.all(files.map(async (f) => {
225
+ try {
226
+ const parsed = JSON.parse(await readFile(f, "utf8"));
227
+ return parsed && typeof parsed === "object" ? parsed : null;
228
+ } catch { return null; }
229
+ }));
230
+ return read.filter(Boolean);
156
231
  }
157
232
 
158
233
  /** The headers every request made with the Claude Code token sends. */
@@ -146,6 +146,20 @@ export function quotaFromStore(entry) {
146
146
  return out;
147
147
  }
148
148
 
149
+ /**
150
+ * Whether a window this reading measured has reset since it was taken: its
151
+ * reset instant is at or before `now`, so the percentage beside it belongs to a
152
+ * window that is over, and a new one has started near empty.
153
+ *
154
+ * Both windows, because both end: a reading from before a weekly reset is as
155
+ * wrong about the week as one from before a 5-hour reset is about the session.
156
+ * A window with no reset instant cannot be said to have passed it.
157
+ */
158
+ export function readingLapsed(reading, now) {
159
+ const past = (at) => typeof at === "number" && at * 1000 <= now;
160
+ return past(reading?.session5hResetAt) || past(reading?.week7dResetAt);
161
+ }
162
+
149
163
  // Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
150
164
  // Claude shows times in the user's local timezone, so parsing as local is correct.
151
165
  // `now` is injectable so the year-boundary case is testable.
@@ -158,6 +172,20 @@ export function parseResetToSec(resetStr, now = Date.now()) {
158
172
  .replace(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i,
159
173
  (_all, h, mm, ampm) => `${h}:${mm ?? "00"} ${ampm}`)
160
174
  .trim();
175
+ // A bare clock reading is what the CLI prints for a reset that falls
176
+ // today, and it has no date to find a year for: with one appended anyway,
177
+ // "4:09 pm 2027" reads as the first of January and the bar counted down
178
+ // to it. It means the next time that reading comes round — today, or
179
+ // tomorrow once today's has gone by. The CLI prints to the minute, so the
180
+ // minute it names is still today's.
181
+ const bare = /^(\d{1,2}):(\d{2}) (am|pm)$/i.exec(norm);
182
+ if (bare && +bare[1] >= 1 && +bare[1] <= 12 && +bare[2] < 60) {
183
+ const hour = (+bare[1] % 12) + (/pm/i.test(bare[3]) ? 12 : 0);
184
+ const d = new Date(now);
185
+ const on = (day) => new Date(d.getFullYear(), d.getMonth(), day, hour, +bare[2]).getTime();
186
+ const today = on(d.getDate());
187
+ return Math.floor((today + 60_000 <= now ? on(d.getDate() + 1) : today) / 1000);
188
+ }
161
189
  // The CLI prints no year, so we have to supply one. Stamping the current
162
190
  // year blindly puts a "Jan 2" reset read on Dec 30 eleven months in the
163
191
  // past, which hides the countdown and pins the pace marker at 100%. A
@@ -53,12 +53,13 @@
53
53
  // quota-oauth.mjs's; running source 3 is quota-cli.mjs's; and the store path's
54
54
  // reset inventory is quota-store-resets.mjs's.
55
55
  import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
56
- import { quotaFromStore, WIN_5H_SEC, WIN_7D_SEC } from "./quota-shape.mjs";
56
+ import { quotaFromStore, readingLapsed, WIN_5H_SEC, WIN_7D_SEC } from "./quota-shape.mjs";
57
57
  import {
58
- SELF_POLL_MS, clearCooldown, coolingDown, cooldownUntil, fetchOAuthUsage, hasSubscriptionCredential,
58
+ SELF_POLL_MS, clearCooldown, coolingDown, cooldownUntil, credentialFingerprint, fetchOAuthUsage,
59
+ hasSubscriptionCredential,
59
60
  } from "./quota-oauth.mjs";
60
61
  import {
61
- accountOf, clearResetCreditsFloor, forgetResetCredits, heldResetCredits, refreshStoreResetCredits,
62
+ accountOf, clearResetCreditsFloor, forgetResetCredits, heldResetCredits, refreshStoreResetCredits, sameAccount,
62
63
  } from "./quota-store-resets.mjs";
63
64
  import { runUsageOnce, quotaClaudeBin } from "./quota-cli.mjs";
64
65
 
@@ -79,6 +80,7 @@ let _cache = null;
79
80
  let _cacheAt = 0;
80
81
  let _inflight = null; // deduplicates concurrent CLI probes
81
82
  let _lastGood = null; // last result that had real quota percentages
83
+ let _lastGoodFor = null; // whose it is — see readFor()
82
84
  let _lastSelfPollAt = 0;
83
85
  // Which account the readings below are about — as a counter, because the
84
86
  // account's identity is not something this module holds. invalidateQuotaCache
@@ -149,7 +151,9 @@ export const QUOTA_DEADLINE_MS = 5_000;
149
151
  */
150
152
  export async function fetchClaudeQuota({ force = false, deadlineMs = 0 } = {}) {
151
153
  const now = Date.now();
152
- if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
154
+ // Not a reading whose window has reset since it was cached, though: inside
155
+ // the minute it is still the newest answer, and it is no longer a true one.
156
+ if (!force && _cache && now - _cacheAt < CACHE_MS && !readingLapsed(_cache, now)) return _cache;
153
157
 
154
158
  // If another CLI probe is already in flight, wait for it instead of spawning a
155
159
  // second concurrent process (which can return empty output and overwrite the
@@ -242,14 +246,41 @@ function notYet(now) {
242
246
  * survives the result cache's minute and comes back under a "stale" label for as
243
247
  * long as the store has nothing to say about the new account.
244
248
  */
245
- function publish(gen, result, at, { good = false } = {}) {
249
+ function publish(gen, result, at, { good = false, who = null } = {}) {
246
250
  if (gen !== _generation) return result;
247
251
  _cache = result;
248
252
  _cacheAt = at;
249
- if (good) _lastGood = result;
253
+ if (good) { _lastGood = result; _lastGoodFor = who; }
250
254
  return result;
251
255
  }
252
256
 
257
+ /**
258
+ * Whose numbers a read that starts now will be: claude-swap's active account
259
+ * when its store names one, and the token in Claude Code's credentials file.
260
+ *
261
+ * invalidateQuotaCache covers the switches the deck makes itself. The rest —
262
+ * `cswap switch` in a terminal, claude-swap's own auto-switch, `claude /login`
263
+ * as somebody else — reach nothing here, and the self-poll floor went on
264
+ * serving the previous account's `_lastGood` for up to five minutes, beside an
265
+ * accounts panel that already showed the new account as live. So `_lastGood`
266
+ * carries this, and a read that finds it changed does not serve it.
267
+ */
268
+ async function readFor(store) {
269
+ return { account: _accountOfReading.get(store) ?? null, token: await credentialFingerprint() };
270
+ }
271
+
272
+ /**
273
+ * Whether the account in use is no longer the one `then` was read for. The
274
+ * account decides whenever both sides name one: a token is refreshed under
275
+ * the same account every few hours, and that is not a switch. Without one,
276
+ * a different token is the only sign there is.
277
+ */
278
+ function switchedSince(then, now) {
279
+ if (!then) return false;
280
+ if (then.account && now.account) return !sameAccount(then.account, now.account);
281
+ return !!(then.token && now.token && then.token !== now.token);
282
+ }
283
+
253
284
  /**
254
285
  * claude-swap's numbers for the active account, if it has any.
255
286
  *
@@ -363,9 +394,17 @@ async function _doFetch(now, force = false, gen = _generation) {
363
394
  // Refresh asks for newer numbers, and the honest way to get them from this
364
395
  // source is to ask the collector that owns it — which applies its own
365
396
  // schedule and backoff, so this cannot become a poll loop.
366
- if (force && (!store || now - store.fetchedAt > FORCE_POLL_MS)) {
397
+ if (force && (!store || now - store.fetchedAt > FORCE_POLL_MS || readingLapsed(store, now))) {
367
398
  store = await nudgeAndReread(store);
368
399
  }
400
+ // A reading held over from an earlier read is only ever served for the
401
+ // account it was read for; see readFor. When the account has moved, the read
402
+ // that answers for the new one may go out on the refresh button's floor:
403
+ // the question is new, and it can be asked at most once per reading held.
404
+ const who = await readFor(store);
405
+ const switched = _lastGood != null && switchedSince(_lastGoodFor, who);
406
+ if (switched) { _lastGood = null; _lastGoodFor = null; }
407
+
369
408
  if (store && now - store.fetchedAt <= STORE_TRUSTED_MS) {
370
409
  // Keep the store moving even when the accounts panel is closed. Without
371
410
  // this the numbers only advance while something else asks — claude-swap's
@@ -373,15 +412,23 @@ async function _doFetch(now, force = false, gen = _generation) {
373
412
  // throttle inside is shared with the accounts panel, so two open panels
374
413
  // ask no more often than one.
375
414
  if (!force) requestCollection().catch(() => {});
376
- // The saved limit resets are the one thing the store cannot say. Read
377
- // behind this answer, never in front of it; see refreshStoreResetCredits.
378
- refreshStoreResetCredits({ now, force, account: _accountOfReading.get(store) });
379
- return publish(gen, store, now, { good: true });
415
+ // A row collected before its window reset is recent and still wrong: the
416
+ // 45 minutes it is trusted for are long enough to hold a 5-hour reset,
417
+ // and claude-swap's rows get that old exactly when its collector is
418
+ // backing off. It is not the answer, so the read goes on below like one
419
+ // for a row that is too old, and the row stays to be held if nothing
420
+ // newer turns up.
421
+ if (!readingLapsed(store, now)) {
422
+ // The saved limit resets are the one thing the store cannot say. Read
423
+ // behind this answer, never in front of it; see refreshStoreResetCredits.
424
+ refreshStoreResetCredits({ now, force, account: _accountOfReading.get(store) });
425
+ return publish(gen, store, now, { good: true, who });
426
+ }
380
427
  }
381
428
 
382
429
  // Nothing usable in the store. Everything below spends the user's budget, so
383
430
  // it happens on a floor, and not at all while a 429 cooldown is running.
384
- if (!maySelfPoll({ now, force, lastSelfPollAt: _lastSelfPollAt, rateLimitedUntil: cooldownUntil() })) {
431
+ if (!maySelfPoll({ now, force: force || switched, lastSelfPollAt: _lastSelfPollAt, rateLimitedUntil: cooldownUntil() })) {
385
432
  // A stale row still beats an empty panel, and says how stale it is — but
386
433
  // it must be the freshest thing we hold, not just the store. Preferring
387
434
  // the store here threw away readings we had already paid for: after a boot
@@ -398,7 +445,7 @@ async function _doFetch(now, force = false, gen = _generation) {
398
445
  // Source 2: OAuth usage API — instant, exact, no cold-start gap.
399
446
  const api = await fetchOAuthUsage();
400
447
  if (api) {
401
- return publish(gen, { ok: true, ...api, source: "api", fetchedAt: now }, now, { good: true });
448
+ return publish(gen, { ok: true, ...api, source: "api", fetchedAt: now }, now, { good: true, who });
402
449
  }
403
450
 
404
451
  // Source 3: parse `claude --print /usage` CLI output.
@@ -406,7 +453,7 @@ async function _doFetch(now, force = false, gen = _generation) {
406
453
 
407
454
  // Got real quota lines — cache normally and remember as last-known-good.
408
455
  if (parsed) {
409
- return publish(gen, { ok: true, ...parsed, source: "cli", fetchedAt: now }, now, { good: true });
456
+ return publish(gen, { ok: true, ...parsed, source: "cli", fetchedAt: now }, now, { good: true, who });
410
457
  }
411
458
 
412
459
  // No quota lines after retries. If we've ever seen real values, keep showing
@@ -415,9 +462,12 @@ async function _doFetch(now, force = false, gen = _generation) {
415
462
  // now" over percentages collected hours earlier for one poll in five, then let
416
463
  // the label snap back to the true age: an age indicator that oscillates, and
417
464
  // vouches for numbers this branch already knows are stale. Short-cache so we
418
- // retry the CLI again soon.
419
- if (_lastGood) {
420
- return publish(gen, { ..._lastGood, stale: true }, shortLived(now));
465
+ // retry the CLI again soon. The store's row is a candidate too, as it is on
466
+ // the floor above: a row passed over because its window reset is still the
467
+ // last thing claude-swap read, and stale says what it is.
468
+ const held = freshest(store, _lastGood);
469
+ if (held) {
470
+ return publish(gen, { ...held, stale: true }, shortLived(now));
421
471
  }
422
472
 
423
473
  // Never had good data. A CLI that RAN and printed no quota lines is two
@@ -498,6 +548,7 @@ export function invalidateQuotaCache() {
498
548
  _cache = null;
499
549
  _cacheAt = 0;
500
550
  _lastGood = null;
551
+ _lastGoodFor = null;
501
552
  _generation++;
502
553
  _inflight = null;
503
554
  // Forgotten with the rest, because it is about the account the deck just
@@ -0,0 +1,87 @@
1
+ // The one question the deck asks about itself: "How useful is ccdeck to you?",
2
+ // 0 to 10, once, after about a week of use.
3
+ //
4
+ // WHEN. Only once the deck has heard a session on RATING_AFTER_DAYS different
5
+ // days (usage-day.mjs keeps that count), only while reports are on — the answer
6
+ // is a report, and a deck that sends none has nobody to tell — and never in the
7
+ // first minutes after a launch, when the board is filling and the person came
8
+ // to look at their sessions, not at us. The page also waits for no dialog to be
9
+ // open (use-rating-ask.ts).
10
+ //
11
+ // HOW OFTEN. "Not now" puts it off by RATING_AGAIN_AFTER_MS, once. A second
12
+ // "Not now", or an answer, and it is never asked again — even after a 3.36.x
13
+ // deck sharing the data dir rewrites prefs.json without it: the outcome is kept
14
+ // a second time beside that file (reports.mjs ratingFile).
15
+ //
16
+ // WHAT LEAVES. The number picked and how many days the deck had been used, as a
17
+ // range (daysUsedBucket) — a "rated" event, sent once (reports.mjs). Nothing
18
+ // about a question left unanswered or put off.
19
+ //
20
+ // Imports nothing, so deck-prefs.mjs can normalise with it.
21
+
22
+ /** Days the deck has to have been used on before it asks. */
23
+ export const RATING_AFTER_DAYS = 7;
24
+
25
+ /** How long "Not now" puts the question off: thirty days. */
26
+ export const RATING_AGAIN_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
27
+
28
+ /** How long after a launch the question waits: the launch is the busy minute. */
29
+ export const RATING_AFTER_LAUNCH_MS = 5 * 60 * 1000;
30
+
31
+ /** How many times "Not now" may be said before the question stops for good. */
32
+ const LATER_LIMIT = 2;
33
+
34
+ /** What prefs.json keeps about the question, coerced: the answer (null until
35
+ * there is one), whether it went out, and how often and when it was put off. */
36
+ export function normaliseRating(raw) {
37
+ const src = raw && typeof raw === "object" ? raw : {};
38
+ const score = Number.isInteger(src.score) && src.score >= 0 && src.score <= 10 ? src.score : null;
39
+ const later = Number.isInteger(src.later) && src.later > 0 ? Math.min(src.later, LATER_LIMIT) : 0;
40
+ return {
41
+ score,
42
+ sent: score !== null && src.sent === true,
43
+ later,
44
+ laterAt: typeof src.laterAt === "string" && !Number.isNaN(Date.parse(src.laterAt)) ? src.laterAt : "",
45
+ };
46
+ }
47
+
48
+ /** How far the question has got: put off none, once or twice, then answered,
49
+ * then answered and sent. Of two copies of it, the further one is the truth —
50
+ * nothing ever moves it back. */
51
+ export function ratingStage(rating) {
52
+ const r = normaliseRating(rating);
53
+ if (r.score === null) return r.later;
54
+ return r.sent ? LATER_LIMIT + 2 : LATER_LIMIT + 1;
55
+ }
56
+
57
+ /** A score the page may send: a whole number from 0 to 10, else null. */
58
+ export function scoreOf(value) {
59
+ return Number.isInteger(value) && value >= 0 && value <= 10 ? value : null;
60
+ }
61
+
62
+ /** How long the deck had been used when it was rated, as the range the API
63
+ * keeps — never the count itself. */
64
+ export function daysUsedBucket(days) {
65
+ if (!Number.isInteger(days) || days < 7) return "1-6";
66
+ if (days < 14) return "7-13";
67
+ if (days < 30) return "14-29";
68
+ if (days < 90) return "30-89";
69
+ return "90+";
70
+ }
71
+
72
+ /**
73
+ * Is it time to ask? The saved state of the question, the days used so far, the
74
+ * time now, and how long this deck has been up. The reports switch is the
75
+ * caller's (reports.mjs), because only it knows the veto.
76
+ */
77
+ export function ratingDue(rating, daysUsed, now, uptimeMs) {
78
+ const r = normaliseRating(rating);
79
+ if (r.score !== null || r.later >= LATER_LIMIT) return false;
80
+ if (!Number.isInteger(daysUsed) || daysUsed < RATING_AFTER_DAYS) return false;
81
+ if (!(uptimeMs >= RATING_AFTER_LAUNCH_MS)) return false;
82
+ if (r.later > 0) {
83
+ const at = Date.parse(r.laterAt);
84
+ if (Number.isNaN(at) || now.getTime() - at < RATING_AGAIN_AFTER_MS) return false;
85
+ }
86
+ return true;
87
+ }
@@ -1,5 +1,5 @@
1
- // The page's ways into reports.mjs (#1853): an error the page caught and the
2
- // feedback dialog. (The switch in Appearance went on 2026-10-01; its route stays.) The page never
1
+ // The page's ways into reports.mjs (#1853): an error the page caught, the
2
+ // feedback dialog, and the one question the deck asks about itself. (The switch in Appearance went on 2026-10-01; its route stays.) The page never
3
3
  // talks to api.ccdeck.dev itself — this server does, which is also why the
4
4
  // install id never has to reach the page.
5
5
 
@@ -28,6 +28,25 @@ export async function handleReportsWrite(req, res, { report = reporter } = {}) {
28
28
  return send(res, 200, { ok: true, reports: heldPrefs.current().reports, reportsVetoed: reportsVetoed() });
29
29
  }
30
30
 
31
+ /** `GET /api/rating`: whether the page should ask "How useful is ccdeck to
32
+ * you?" now (rating.mjs says when). Nothing leaves the machine. */
33
+ export function handleRatingRead(_req, res, { report = reporter } = {}) {
34
+ return send(res, 200, { ok: true, ask: report.ratingAsk() });
35
+ }
36
+
37
+ /** `POST /api/rating` `{ score }` or `{ later: true }`: the question answered,
38
+ * 0 to 10, or put off. An answer is sent on as a "rated" report; "Not now" is
39
+ * kept here and goes nowhere. 409 when there is nothing to keep it under —
40
+ * reports off, or already answered. */
41
+ export async function handleRatingWrite(req, res, { report = reporter } = {}) {
42
+ const body = await readJson(req, res, 1_000);
43
+ if (body?.later === true) {
44
+ return (await report.rateLater()) ? send(res, 200, { ok: true }) : send(res, 409, { ok: false, reason: "not_asking" });
45
+ }
46
+ if (!Number.isInteger(body?.score) || body.score < 0 || body.score > 10) return send(res, 400, { ok: false, reason: "bad_request" });
47
+ return (await report.rate(body.score)) ? send(res, 200, { ok: true }) : send(res, 409, { ok: false, reason: "not_asking" });
48
+ }
49
+
31
50
  /** `POST /api/client-error` `{ message, stack? }`: an error the page caught. Sent on only while reports are on. */
32
51
  export async function handleClientError(req, res, { report = reporter } = {}) {
33
52
  const body = await readJson(req, res, 16_000);