quotacap 0.0.45 → 0.0.47

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.
@@ -35,6 +35,13 @@ export declare function refreshKimiOAuthToken(oauthHost: string, refreshToken: s
35
35
  refresh_token?: string;
36
36
  expires_in?: number;
37
37
  }>;
38
+ /** The Kimi account has no active subscription (observed: cancelled plan
39
+ * answers /usages 200 with `{}`, /me `user_level_name` "Free", and the TUI
40
+ * shows "No usage data available"). The TUI reports the same state, so the
41
+ * PTY fallback is skipped: report it, do not mask it with a timeout. */
42
+ export declare class KimiSubscriptionInactiveError extends Error {
43
+ constructor(detail: string);
44
+ }
38
45
  /** A refresh rotated the tokens but the result could not be saved for the Kimi CLI. */
39
46
  export declare class KimiCredentialWriteError extends Error {
40
47
  constructor(credPath: string, cause: unknown);
@@ -153,6 +153,14 @@ function parseKimiApiMonthly(u, legacyUsages) {
153
153
  }
154
154
  return {};
155
155
  }
156
+ function isEmptyUsageBody(u) {
157
+ const usage = u.usage;
158
+ const hasUsage = !!usage && typeof usage === "object" && Object.keys(usage).length > 0;
159
+ const hasLimits = Array.isArray(u.limits) && u.limits.length > 0;
160
+ const usages = u.usages;
161
+ const hasUsages = !!usages && typeof usages === "object" && Object.keys(usages).length > 0;
162
+ return !hasUsage && !hasLimits && !hasUsages;
163
+ }
156
164
  export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
157
165
  if (typeof usagesBody !== "object" || usagesBody === null) {
158
166
  throw new Error("kimi: invalid api response");
@@ -212,6 +220,21 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
212
220
  }
213
221
  }
214
222
  }
223
+ // 3. Plan identity (from meBody), before the field throws: an empty
224
+ // usage body on a free plan is a dead subscription, not a parse failure.
225
+ let plan = "unknown";
226
+ if (meBody && typeof meBody === "object") {
227
+ const me = meBody;
228
+ if (typeof me.user_level_name === "string" && me.user_level_name.trim().length > 0) {
229
+ plan = me.user_level_name.trim().toLowerCase();
230
+ }
231
+ }
232
+ if (weeklyPct === null && sessionPct === null && !resetsAt && isEmptyUsageBody(u) && plan === "free") {
233
+ throw new KimiSubscriptionInactiveError("plan free, empty usage response");
234
+ }
235
+ // Any other shape without data stays a parse error: an empty body on a
236
+ // paid or unknown plan falls through to the PTY path, whose transcript
237
+ // decides (usage panel vs "No usage data available").
215
238
  if (weeklyPct === null || weeklyPct < 0 || weeklyPct > 100) {
216
239
  throw new Error("kimi: weekly pct not found in api response");
217
240
  }
@@ -221,14 +244,6 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
221
244
  if (!resetsAt || Number.isNaN(new Date(resetsAt).getTime())) {
222
245
  throw new Error("kimi: weekly reset not found in api response");
223
246
  }
224
- // 3. Plan identity (from meBody)
225
- let plan = "unknown";
226
- if (meBody && typeof meBody === "object") {
227
- const me = meBody;
228
- if (typeof me.user_level_name === "string" && me.user_level_name.trim().length > 0) {
229
- plan = me.user_level_name.trim().toLowerCase();
230
- }
231
- }
232
247
  // 4. Optional monthly quota (from limit_month_total, limits, or subscriptionBalance)
233
248
  const monthly = parseKimiApiMonthly(u, legacyUsages);
234
249
  const periodStart = new Date(new Date(resetsAt).getTime() - 7 * 86400000).toISOString();
@@ -377,6 +392,16 @@ export async function refreshKimiOAuthToken(oauthHost, refreshToken, deviceId, s
377
392
  expires_in: typeof data.expires_in === "number" ? data.expires_in : undefined,
378
393
  };
379
394
  }
395
+ /** The Kimi account has no active subscription (observed: cancelled plan
396
+ * answers /usages 200 with `{}`, /me `user_level_name` "Free", and the TUI
397
+ * shows "No usage data available"). The TUI reports the same state, so the
398
+ * PTY fallback is skipped: report it, do not mask it with a timeout. */
399
+ export class KimiSubscriptionInactiveError extends Error {
400
+ constructor(detail) {
401
+ super(`kimi: subscription inactive (${detail})`);
402
+ this.name = "KimiSubscriptionInactiveError";
403
+ }
404
+ }
380
405
  /** A refresh rotated the tokens but the result could not be saved for the Kimi CLI. */
381
406
  export class KimiCredentialWriteError extends Error {
382
407
  constructor(credPath, cause) {
@@ -463,6 +488,11 @@ export async function pollKimiApi(ctx) {
463
488
  headers.Authorization = `Bearer ${freshToken}`;
464
489
  usagesRes = await fetch(usagesUrl, { headers, signal });
465
490
  }
491
+ // 402/403 on an authenticated /usages call is an entitlement state, not
492
+ // a transport failure: the account has no active subscription.
493
+ if (usagesRes.status === 402 || usagesRes.status === 403) {
494
+ throw new KimiSubscriptionInactiveError(`HTTP ${usagesRes.status}`);
495
+ }
466
496
  if (!usagesRes.ok) {
467
497
  throw new Error(`kimi: usage request failed (HTTP ${usagesRes.status})`);
468
498
  }
@@ -520,9 +550,13 @@ export const kimiAdapter = {
520
550
  return await pollKimiApi(authCtx);
521
551
  }
522
552
  catch (e) {
523
- // A lost rotated token leaves the CLI logged out: report it, do not mask it with the PTY path.
553
+ // A lost rotated token leaves the CLI logged out, and a dead
554
+ // subscription reads the same in the TUI: report either, do not
555
+ // mask them with the PTY path.
524
556
  if (e instanceof KimiCredentialWriteError)
525
557
  throw e;
558
+ if (e instanceof KimiSubscriptionInactiveError)
559
+ throw e;
526
560
  }
527
561
  }
528
562
  return pollKimiPty();
@@ -97,7 +97,10 @@ export function buildSnapshot(db, opts) {
97
97
  providerFailed = true;
98
98
  }
99
99
  }
100
- // Precedence: invalid, reset-passed, provider-failed, stale, not-reporting
100
+ // Precedence: invalid, provider-failed, reset-passed, stale, not-reporting.
101
+ // A fresh failure outranks a passed reset: "poll again to capture the new
102
+ // window" is only actionable when polls succeed, and would otherwise mask
103
+ // the real state (e.g. an inactive subscription) behind a re-poll prompt.
101
104
  let exclusionReason = null;
102
105
  if (quota === null) {
103
106
  exclusionReason = "not-reporting";
@@ -105,12 +108,12 @@ export function buildSnapshot(db, opts) {
105
108
  else if (isInvalid) {
106
109
  exclusionReason = "invalid";
107
110
  }
108
- else if (resetPassed) {
109
- exclusionReason = "reset-passed";
110
- }
111
111
  else if (providerFailed) {
112
112
  exclusionReason = "provider-failed";
113
113
  }
114
+ else if (resetPassed) {
115
+ exclusionReason = "reset-passed";
116
+ }
114
117
  else if (stale) {
115
118
  exclusionReason = "stale";
116
119
  }
@@ -86,6 +86,15 @@ const UNRECOGNIZED_OMITTED = "Unrecognized diagnostic text omitted";
86
86
  // "account is not eligible" is the Antigravity sentence; a bare "not eligible" is not.
87
87
  // The hyphen covers "Sign-in with". "signed in with" does not match.
88
88
  const ACCOUNT_CONFIRMATION = /\b(?:account is not eligible|verify your account|eligibility check failed|device code|sign(?:ing)?[ -]in with)\b/i;
89
+ // Cancelled- or missing-subscription wording. The QuotaCap-authored phrase
90
+ // ("subscription inactive", thrown by adapters that detect it directly) plus
91
+ // Kimi's own sentences for the same state: the /usage panel shows "No usage
92
+ // data available" and prompting fails with "[provider.auth_error] 403 ...
93
+ // does not have access to Kimi Code ... Upgrade your plan". Bare "does not
94
+ // have access" is not enough: it must name Kimi Code, so file-permission
95
+ // errors never match. A bare "Upgrade your plan" is not enough either: it
96
+ // can be upsell banner text on an otherwise healthy screen.
97
+ const SUBSCRIPTION_INACTIVE = /\bsubscription inactive\b|\bno usage data available\b|\bprovider\.auth_error\b|\bdoes not have access to Kimi Code\b/i;
89
98
  function matchPrecedence(evidence, errorName, errorCode, text) {
90
99
  // Order 1: timeout — Structured AbortError or an explicit QuotaCap abort/timeout checkpoint.
91
100
  // A ready or completion timeout whose transcript is an account-confirmation
@@ -93,7 +102,13 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
93
102
  // abort stays a timeout even if that screen is in the transcript.
94
103
  const signInTimeout = ACCOUNT_CONFIRMATION.test(text) &&
95
104
  (evidence?.checkpoint === "ready timeout" || evidence?.checkpoint === "completion timeout");
105
+ // Same fall-through for a dead subscription: a completion timeout whose
106
+ // stable transcript is the empty usage panel reports the subscription
107
+ // state, not the timeout that a missing completion pattern caused.
108
+ const inactiveTimeout = SUBSCRIPTION_INACTIVE.test(text) &&
109
+ (evidence?.checkpoint === "ready timeout" || evidence?.checkpoint === "completion timeout");
96
110
  const isAbort = !signInTimeout &&
111
+ !inactiveTimeout &&
97
112
  (errorName === "AbortError" ||
98
113
  errorCode === "ABORT_ERR" ||
99
114
  evidence?.checkpoint === "abort" ||
@@ -158,6 +173,12 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
158
173
  }
159
174
  // Order 6: auth — login required, not logged in, logged out, unauthorized, unauthorised, forbidden,
160
175
  // run <provider> login to continue, or error indicating missing, invalid, or expired credentials, authentication, or API key
176
+ // A dead subscription sorts as auth (the 403 is an authorization state),
177
+ // first in this order so its summary wins over login wording: the user is
178
+ // logged in, and "login required" would mislead.
179
+ if (SUBSCRIPTION_INACTIVE.test(text)) {
180
+ return { code: "auth", phrase: "subscription inactive" };
181
+ }
161
182
  if (/\brun\s+[a-z0-9_:-]+\s+login\s+to\s+continue\b/i.test(text)) {
162
183
  return { code: "auth", phrase: "run <provider> login to continue" };
163
184
  }
@@ -424,7 +445,8 @@ export function diagnosticError(reason, evidence) {
424
445
  const matchedLines = lines.filter((l) => /\b(error|failed|failure|fatal|unauthorized|unauthorised|denied|forbidden|panic|exception)\b/i.test(l) ||
425
446
  /\btrust\b/i.test(l) ||
426
447
  /\b(stdin is not a terminal|device not configured|not a tty|inappropriate ioctl|node-pty)\b/i.test(l) ||
427
- ACCOUNT_CONFIRMATION.test(l));
448
+ ACCOUNT_CONFIRMATION.test(l) ||
449
+ SUBSCRIPTION_INACTIVE.test(l));
428
450
  safeStdoutText = matchedLines.join(" ");
429
451
  }
430
452
  else {
@@ -497,6 +519,12 @@ function getSummaryAndAction(code, providerName, provider, errorDetail) {
497
519
  action: `Open ${providerName} directly in your terminal to check whether it starts. If it works there, inspect the QuotaCap service log and report the adapter failure. Refresh in the dashboard retries through the same service.`,
498
520
  };
499
521
  case "auth":
522
+ if (/\bsubscription inactive\b/i.test(errorDetail)) {
523
+ return {
524
+ summary: `${providerName} subscription inactive`,
525
+ action: `Open ${providerName} to manage your subscription, or run \`quotacap providers disable ${provider}\` to stop polling it. Then select Refresh in the QuotaCap dashboard.`,
526
+ };
527
+ }
500
528
  if (/\baccount confirmation required\b/i.test(errorDetail)) {
501
529
  return {
502
530
  summary: `${providerName} needs you to confirm the subscription account`,
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.45";
2
+ export const VERSION = "0.0.47";
@@ -1,8 +1,12 @@
1
1
  import React from "react";
2
2
  import type { ProviderView } from "../state.js";
3
3
  export declare const RAIL_DAYS = 7;
4
+ /** Stem height; every pin sits at tier 0, so the rail never grows. */
5
+ export declare const STEM_PX = 14;
4
6
  export interface RailRow {
5
7
  id: string;
8
+ /** Pill text, used to estimate the width for dodge packing. */
9
+ label: string;
6
10
  resetsAt: string | null;
7
11
  estimated?: boolean;
8
12
  }
@@ -10,13 +14,16 @@ export type RailBand = "above" | "below";
10
14
  export interface PlacedPin extends RailRow {
11
15
  positionPct: number;
12
16
  band: RailBand;
13
- tier: number;
17
+ /** Sideways pill offset in px; the dot stays on the time coordinate. */
18
+ offsetPx: number;
14
19
  }
15
20
  export interface PinCluster {
16
21
  ids: string[];
22
+ label: string;
17
23
  positionPct: number;
18
24
  band: RailBand;
19
- tier: number;
25
+ /** Sideways pill offset in px; the dot stays on the time coordinate. */
26
+ offsetPx: number;
20
27
  }
21
28
  export interface PinPlacement {
22
29
  placed: PlacedPin[];
@@ -24,15 +31,29 @@ export interface PinPlacement {
24
31
  overflow: RailRow[];
25
32
  invalid: RailRow[];
26
33
  }
34
+ /** Half the pill width for a display name: 16ch cap, ~7px per char + chrome. */
35
+ export declare function pillHalfWidth(label: string): number;
36
+ /** Half the timestamp width: tabular mono, ~6.6px per char + padding. */
37
+ export declare function stampHalfWidth(text: string): number;
38
+ export type RailMode = "separate" | "grouped";
39
+ export declare const RAIL_MODE_KEY = "quotacap-rail-mode";
40
+ export declare function storedRailMode(): RailMode;
27
41
  /**
28
42
  * Place reset pins proportionally over the next 7 days from `asOf`.
29
- * Crowded windows past tier capacity group into clusters; resets beyond the
30
- * rail overflow; invalid rows are reported; reset-passed rows place no pin
31
- * (the ledger shows "awaiting fresh window" for them).
43
+ * Same-band pills that would overlap dodge sideways by pill width (earlier
44
+ * left, later right); crowds past the spread cap shingle with small overlap
45
+ * instead, resolved by hover. Dots stay on the time coordinate and the rail
46
+ * stays flat. Resets beyond the rail overflow; invalid rows are reported;
47
+ * reset-passed rows place no pin (the ledger shows "awaiting fresh window"
48
+ * for them). Grouped mode collapses crowded windows into cluster pins.
32
49
  */
33
- export declare function placePins(rows: RailRow[], asOf: Date): PinPlacement;
34
- export declare function ResetRail({ providers, asOf, onSelectProvider, }: {
50
+ export declare function placePins(rows: RailRow[], asOf: Date, opts?: {
51
+ grouped?: boolean;
52
+ railWidthPx?: number;
53
+ }): PinPlacement;
54
+ export declare function ResetRail({ providers, asOf, onSelectProvider, initialMode, }: {
35
55
  providers: ProviderView[];
36
56
  asOf: string;
37
57
  onSelectProvider: (id: string) => void;
58
+ initialMode?: RailMode;
38
59
  }): React.JSX.Element;
@@ -3,17 +3,60 @@ import { useEffect, useRef, useState } from "react";
3
3
  import { paceBadge, resetClock, resetCountdown, timeLeft } from "./PaceBar.js";
4
4
  export const RAIL_DAYS = 7;
5
5
  const RAIL_MS = RAIL_DAYS * 24 * 60 * 60 * 1000;
6
- /** Pins within this span of rail group into one collision window. */
6
+ /** Pins within this span of rail group into one collision window (grouped mode). */
7
7
  const COLLISION_PCT = 3;
8
- /** Pins per window before they group into a cluster pin. */
9
- const WINDOW_CAPACITY = 3;
8
+ /** Pins per window past which they group into a cluster pin (grouped mode). */
9
+ const WINDOW_CAPACITY = 2;
10
+ /** Stem height; every pin sits at tier 0, so the rail never grows. */
11
+ export const STEM_PX = 14;
12
+ /** Layout width before the rail measures itself (dashboard content max). */
13
+ const RAIL_REF_PX = 1072;
14
+ /** Gap kept between dodged pills. */
15
+ const DODGE_GAP_PX = 6;
16
+ /** Widest one dodge group may spread; past this pills shingle with overlap. */
17
+ const MAX_GROUP_SPREAD_PX = 160;
18
+ /** Half the pill width for a display name: 16ch cap, ~7px per char + chrome. */
19
+ export function pillHalfWidth(label) {
20
+ return (Math.min(label.length, 16) * 7 + 30) / 2;
21
+ }
22
+ /** Half the timestamp width: tabular mono, ~6.6px per char + padding. */
23
+ export function stampHalfWidth(text) {
24
+ return (text.length * 6.6 + 12) / 2;
25
+ }
26
+ function pinHalfWidth(pin) {
27
+ // Candidates reaching layout always parsed, but the row type allows null.
28
+ const clock = pin.resetsAt === null ? "" : (resetClock(pin.resetsAt) ?? "");
29
+ const stamp = `${clock}${pin.estimated ? " (est.)" : ""}`;
30
+ return Math.max(pillHalfWidth(pin.label), stampHalfWidth(stamp));
31
+ }
32
+ export const RAIL_MODE_KEY = "quotacap-rail-mode";
33
+ export function storedRailMode() {
34
+ try {
35
+ const v = typeof localStorage === "undefined" ? null : localStorage.getItem(RAIL_MODE_KEY);
36
+ return v === "grouped" ? "grouped" : "separate";
37
+ }
38
+ catch {
39
+ return "separate";
40
+ }
41
+ }
42
+ function persistRailMode(mode) {
43
+ try {
44
+ localStorage.setItem(RAIL_MODE_KEY, mode);
45
+ }
46
+ catch {
47
+ // Private mode or non-DOM: the choice simply does not persist.
48
+ }
49
+ }
10
50
  /**
11
51
  * Place reset pins proportionally over the next 7 days from `asOf`.
12
- * Crowded windows past tier capacity group into clusters; resets beyond the
13
- * rail overflow; invalid rows are reported; reset-passed rows place no pin
14
- * (the ledger shows "awaiting fresh window" for them).
52
+ * Same-band pills that would overlap dodge sideways by pill width (earlier
53
+ * left, later right); crowds past the spread cap shingle with small overlap
54
+ * instead, resolved by hover. Dots stay on the time coordinate and the rail
55
+ * stays flat. Resets beyond the rail overflow; invalid rows are reported;
56
+ * reset-passed rows place no pin (the ledger shows "awaiting fresh window"
57
+ * for them). Grouped mode collapses crowded windows into cluster pins.
15
58
  */
16
- export function placePins(rows, asOf) {
59
+ export function placePins(rows, asOf, opts) {
17
60
  const asOfMs = asOf.getTime();
18
61
  const placed = [];
19
62
  const clusters = [];
@@ -48,41 +91,87 @@ export function placePins(rows, asOf) {
48
91
  }
49
92
  const items = [];
50
93
  for (const window of windows) {
51
- if (window.length <= WINDOW_CAPACITY) {
94
+ if (opts?.grouped && window.length > WINDOW_CAPACITY) {
95
+ const positionPct = window.reduce((sum, c) => sum + c.positionPct, 0) / window.length;
96
+ const ids = window.map((c) => c.row.id);
97
+ items.push({
98
+ kind: "cluster",
99
+ cluster: { ids, label: `${ids.length} resets`, positionPct, band: "above", offsetPx: 0 },
100
+ });
101
+ }
102
+ else {
52
103
  for (const c of window) {
53
104
  items.push({
54
105
  kind: "pin",
55
- pin: { ...c.row, positionPct: c.positionPct, band: "above", tier: 0 },
106
+ pin: { ...c.row, positionPct: c.positionPct, band: "above", offsetPx: 0 },
56
107
  });
57
108
  }
58
109
  }
59
- else {
60
- const positionPct = window.reduce((sum, c) => sum + c.positionPct, 0) / window.length;
61
- items.push({
62
- kind: "cluster",
63
- cluster: { ids: window.map((c) => c.row.id), positionPct, band: "above", tier: 0 },
64
- });
65
- }
66
110
  }
67
111
  items.sort((a, b) => {
68
112
  const pa = a.kind === "pin" ? a.pin.positionPct : a.cluster.positionPct;
69
113
  const pb = b.kind === "pin" ? b.pin.positionPct : b.cluster.positionPct;
70
114
  return pa - pb;
71
115
  });
72
- // Alternate above/below bands; stagger tiers when same-band items crowd.
73
- const lastByBand = {
74
- above: { position: -Infinity, tier: 0 },
75
- below: { position: -Infinity, tier: 0 },
76
- };
116
+ // Alternate above/below bands so time-adjacent pins never share a side.
77
117
  items.forEach((item, index) => {
78
118
  const target = item.kind === "pin" ? item.pin : item.cluster;
79
- const band = index % 2 === 0 ? "above" : "below";
80
- const prev = lastByBand[band];
81
- const tier = target.positionPct - prev.position < COLLISION_PCT * 2 ? prev.tier + 1 : 0;
82
- target.band = band;
83
- target.tier = tier;
84
- lastByBand[band] = { position: target.positionPct, tier };
119
+ target.band = index % 2 === 0 ? "above" : "below";
85
120
  });
121
+ const width = opts?.railWidthPx && opts.railWidthPx > 0 ? opts.railWidthPx : RAIL_REF_PX;
122
+ const cap = Math.min(MAX_GROUP_SPREAD_PX, width * 0.3);
123
+ for (const band of ["above", "below"]) {
124
+ const lane = items
125
+ .filter((item) => (item.kind === "pin" ? item.pin.band : item.cluster.band) === band)
126
+ .map((item) => {
127
+ const target = item.kind === "pin" ? item.pin : item.cluster;
128
+ return {
129
+ target,
130
+ x: (target.positionPct / 100) * width,
131
+ half: item.kind === "pin" ? pinHalfWidth(item.pin) : pillHalfWidth(item.cluster.label),
132
+ };
133
+ });
134
+ // Chain natural extents into dodge groups.
135
+ const groups = [];
136
+ let group = [];
137
+ let edge = -Infinity;
138
+ const flush = () => {
139
+ if (group.length > 0)
140
+ groups.push(group);
141
+ group = [];
142
+ edge = -Infinity;
143
+ };
144
+ for (const slot of lane) {
145
+ if (slot.x - slot.half > edge + DODGE_GAP_PX)
146
+ flush();
147
+ group.push(slot);
148
+ edge = Math.max(edge, slot.x + slot.half);
149
+ }
150
+ flush();
151
+ for (const g of groups) {
152
+ if (g.length === 1)
153
+ continue; // offsetPx stays 0
154
+ const mid = (g[0].x + g[g.length - 1].x) / 2;
155
+ const totalW = g.reduce((sum, s) => sum + s.half * 2, 0) + DODGE_GAP_PX * (g.length - 1);
156
+ let cursor = mid - totalW / 2;
157
+ let centers = g.map((s) => {
158
+ const center = cursor + s.half;
159
+ cursor += s.half * 2 + DODGE_GAP_PX;
160
+ return center;
161
+ });
162
+ const boxSpan = centers[centers.length - 1] + g[g.length - 1].half - (centers[0] - g[0].half);
163
+ if (boxSpan > cap)
164
+ centers = centers.map((c) => mid + ((c - mid) * cap) / boxSpan);
165
+ const shift = centers[0] - g[0].half < 0
166
+ ? -(centers[0] - g[0].half)
167
+ : centers[centers.length - 1] + g[g.length - 1].half > width
168
+ ? width - (centers[centers.length - 1] + g[g.length - 1].half)
169
+ : 0;
170
+ g.forEach((s, i) => {
171
+ s.target.offsetPx = centers[i] + shift - s.x;
172
+ });
173
+ }
174
+ }
86
175
  for (const item of items) {
87
176
  if (item.kind === "pin")
88
177
  placed.push(item.pin);
@@ -154,16 +243,59 @@ function dayLabels(asOfMs) {
154
243
  return `${weekday} ${day}`;
155
244
  });
156
245
  }
157
- export function ResetRail({ providers, asOf, onSelectProvider, }) {
246
+ /**
247
+ * Stem from the dot to the pill: vertical when centered, a short diagonal
248
+ * when the pill dodged sideways. The over-tall layout box is pulled back
249
+ * with a negative margin so the pill meets the stem's visual end.
250
+ */
251
+ function PinStem({ band, offsetPx }) {
252
+ if (offsetPx === 0)
253
+ return _jsx("span", { className: "pin-stem" });
254
+ const dx = Math.abs(offsetPx);
255
+ const len = Math.sqrt(dx * dx + STEM_PX * STEM_PX);
256
+ const angle = (Math.atan2(dx, STEM_PX) * 180) / Math.PI;
257
+ const deg = band === "above" ? Math.sign(offsetPx) * angle : -Math.sign(offsetPx) * angle;
258
+ const pull = len - STEM_PX;
259
+ const style = band === "above"
260
+ ? { height: len, marginTop: -pull, transform: `rotate(${deg}deg)` }
261
+ : { height: len, marginBottom: -pull, transform: `rotate(${deg}deg)` };
262
+ return _jsx("span", { className: "pin-stem", style: style });
263
+ }
264
+ export function ResetRail({ providers, asOf, onSelectProvider, initialMode, }) {
158
265
  const [openCluster, setOpenCluster] = useState(null);
266
+ const [mode, setMode] = useState(() => initialMode ?? storedRailMode());
267
+ const [railWidth, setRailWidth] = useState(RAIL_REF_PX);
268
+ const railRef = useRef(null);
269
+ useEffect(() => {
270
+ const el = railRef.current;
271
+ if (!el || typeof ResizeObserver === "undefined")
272
+ return;
273
+ const ro = new ResizeObserver((entries) => {
274
+ const w = entries[0]?.contentRect.width;
275
+ if (w && w > 0)
276
+ setRailWidth(w);
277
+ });
278
+ ro.observe(el);
279
+ return () => ro.disconnect();
280
+ }, []);
159
281
  const asOfMs = Date.parse(asOf);
160
282
  const byId = new Map(providers.map((p) => [p.id, p]));
161
283
  const rows = providers
162
284
  .filter((p) => p.enabled && p.quota)
163
- .map((p) => ({ id: p.id, resetsAt: p.quota.resetsAt, estimated: !!p.quota.resetsAtEstimated }));
164
- const placement = placePins(rows, new Date(asOfMs));
285
+ .map((p) => ({
286
+ id: p.id,
287
+ label: p.displayName ?? p.id,
288
+ resetsAt: p.quota.resetsAt,
289
+ estimated: !!p.quota.resetsAtEstimated,
290
+ }));
291
+ const placement = placePins(rows, new Date(asOfMs), { grouped: mode === "grouped", railWidthPx: railWidth });
165
292
  const labels = dayLabels(asOfMs);
166
- return (_jsxs("section", { "aria-label": "Upcoming resets", children: [_jsxs("div", { className: "section-head", children: [_jsx("h2", { id: "resets-h", children: "Upcoming resets" }), _jsxs("span", { className: "note", children: ["Next 7 days \u00B7 hover pin for usage \u00B7 click for details \u00B7 ", tzAbbrev(), placement.overflow.length > 0 && (_jsxs("span", { "data-testid": "rail-overflow", title: placement.overflow.map((o) => byId.get(o.id)?.displayName ?? o.id).join(", "), style: { marginLeft: 8, fontWeight: 600, color: "var(--ahead)" }, children: ["+", placement.overflow.length, " beyond rail"] }))] })] }), _jsx("div", { "data-testid": "rail", className: "rail-scroller", tabIndex: 0, role: "region", "aria-label": `Upcoming resets over the next ${RAIL_DAYS} days`, children: _jsxs("div", { className: "rail", children: [_jsx("div", { className: "rail-grid", "aria-hidden": "true", children: Array.from({ length: RAIL_DAYS }, (_, i) => (_jsx("span", {}, i))) }), _jsx("div", { className: "rail-axis", "aria-hidden": "true" }), _jsx("div", { className: "rail-now", "aria-hidden": "true" }), _jsx("span", { className: "rail-nowlabel", "aria-hidden": "true", children: "Now" }), placement.placed.map((pin) => {
293
+ const setModePersist = (next) => {
294
+ setOpenCluster(null);
295
+ setMode(next);
296
+ persistRailMode(next);
297
+ };
298
+ return (_jsxs("section", { "aria-label": "Upcoming resets", children: [_jsxs("div", { className: "section-head", children: [_jsx("h2", { id: "resets-h", children: "Upcoming resets" }), _jsxs("div", { className: "section-head-controls", children: [_jsxs("span", { className: "note", children: ["Next 7 days \u00B7 hover pin for usage \u00B7 click for details \u00B7 ", tzAbbrev(), placement.overflow.length > 0 && (_jsxs("span", { "data-testid": "rail-overflow", title: placement.overflow.map((o) => byId.get(o.id)?.displayName ?? o.id).join(", "), style: { marginLeft: 8, fontWeight: 600, color: "var(--ahead)" }, children: ["+", placement.overflow.length, " beyond rail"] }))] }), _jsxs("div", { "data-testid": "rail-mode", role: "group", "aria-label": "Reset pin grouping", className: "seg", children: [_jsx("button", { type: "button", "aria-pressed": mode === "separate", onClick: () => setModePersist("separate"), children: "Separate" }), _jsx("button", { type: "button", "aria-pressed": mode === "grouped", onClick: () => setModePersist("grouped"), children: "Grouped" })] })] })] }), _jsx("div", { "data-testid": "rail", className: "rail-scroller", tabIndex: 0, role: "region", "aria-label": `Upcoming resets over the next ${RAIL_DAYS} days`, children: _jsxs("div", { className: "rail", ref: railRef, children: [_jsx("div", { className: "rail-grid", "aria-hidden": "true", children: Array.from({ length: RAIL_DAYS }, (_, i) => (_jsx("span", {}, i))) }), _jsx("div", { className: "rail-axis", "aria-hidden": "true" }), _jsx("div", { className: "rail-now", "aria-hidden": "true" }), _jsx("span", { className: "rail-nowlabel", "aria-hidden": "true", children: "Now" }), placement.placed.map((pin) => {
167
299
  const provider = byId.get(pin.id);
168
300
  const when = provider?.quota ? pinWhen(provider.quota.resetsAt) : provider ? resetCountdown(provider, asOfMs) : "";
169
301
  const pace = provider ? paceBadge(provider) : "";
@@ -171,20 +303,20 @@ export function ResetRail({ providers, asOf, onSelectProvider, }) {
171
303
  const name = provider?.displayName ?? pin.id;
172
304
  const { cls: paceCls, color: paceColor } = pinPace(provider);
173
305
  const alignClass = pin.positionPct < 12 ? "pin-start" : pin.positionPct > 88 ? "pin-end" : "";
174
- const tierClass = pin.tier > 0 ? `tier${pin.tier + 1}` : "tier1";
175
306
  const pinBorder = {
176
307
  ["--pin-border"]: `color-mix(in oklch, ${paceColor} 45%, var(--line))`,
177
308
  };
178
- return (_jsxs("button", { "data-testid": "pin", type: "button", className: `pin ${pin.band} ${tierClass} ${alignClass}`, style: {
309
+ const dodgeX = pin.offsetPx === 0 ? undefined : { translate: `${pin.offsetPx}px 0` };
310
+ return (_jsxs("button", { "data-testid": "pin", type: "button", className: `pin ${pin.band} ${alignClass}`, style: {
179
311
  left: `${pin.positionPct}%`,
180
- "--dot-color": paceColor,
181
- }, "aria-label": `${name}: ${pace}, resets ${when}`, onClick: () => onSelectProvider(pin.id), children: [pin.band === "above" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-when", children: pin.estimated ? `${when} (est.)` : when }), _jsx("span", { className: `pin-label ${paceCls}`, style: pinBorder, children: name }), _jsx("span", { className: "pin-stem" }), _jsx("span", { className: "pin-dot" })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-dot" }), _jsx("span", { className: "pin-stem" }), _jsx("span", { className: `pin-label ${paceCls}`, style: pinBorder, children: name }), _jsx("span", { className: "pin-when", children: pin.estimated ? `${when} (est.)` : when })] })), _jsxs("span", { className: "pin-tooltip", role: "tooltip", children: [_jsx("b", { children: name }), _jsx("span", { className: "pt-sep", children: "\u00B7" }), provider?.quota ? `${provider.quota.weeklyPct}% used` : "no readings", _jsx("span", { className: "pt-sep", children: "\u00B7" }), left ? `${left} left` : when, pin.estimated ? " (est.)" : ""] })] }, pin.id));
312
+ ["--dot-color"]: paceColor,
313
+ }, "aria-label": `${name}: ${pace}, resets ${when}`, onClick: () => onSelectProvider(pin.id), children: [pin.band === "above" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-when", style: dodgeX, children: pin.estimated ? `${when} (est.)` : when }), _jsx("span", { className: `pin-label ${paceCls}`, style: { ...pinBorder, ...dodgeX }, children: name }), _jsx(PinStem, { band: pin.band, offsetPx: pin.offsetPx }), _jsx("span", { className: "pin-dot" })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-dot" }), _jsx(PinStem, { band: pin.band, offsetPx: pin.offsetPx }), _jsx("span", { className: `pin-label ${paceCls}`, style: { ...pinBorder, ...dodgeX }, children: name }), _jsx("span", { className: "pin-when", style: dodgeX, children: pin.estimated ? `${when} (est.)` : when })] })), _jsxs("span", { className: "pin-tooltip", role: "tooltip", children: [_jsx("b", { children: name }), _jsx("span", { className: "pt-sep", children: "\u00B7" }), provider?.quota ? `${provider.quota.weeklyPct}% used` : "no readings", _jsx("span", { className: "pt-sep", children: "\u00B7" }), left ? `${left} left` : when, pin.estimated ? " (est.)" : ""] })] }, pin.id));
182
314
  }), placement.clusters.map((cluster, index) => {
183
315
  const alignClass = cluster.positionPct < 12 ? "pin-start" : cluster.positionPct > 88 ? "pin-end" : "";
184
- const tierClass = cluster.tier > 0 ? `tier${cluster.tier + 1}` : "tier1";
185
- return (_jsx("button", { "data-testid": "cluster-pin", type: "button", className: `pin ${cluster.band} ${tierClass} ${alignClass}`, style: {
316
+ const dodgeX = cluster.offsetPx === 0 ? undefined : { translate: `${cluster.offsetPx}px 0` };
317
+ return (_jsx("button", { "data-testid": "cluster-pin", type: "button", className: `pin ${cluster.band} ${alignClass}`, style: {
186
318
  left: `${cluster.positionPct}%`,
187
319
  "--dot-color": "var(--accent)",
188
- }, "aria-label": `${cluster.ids.length} resets at the same time: ${cluster.ids.map((id) => byId.get(id)?.displayName ?? id).join(", ")}`, "aria-expanded": openCluster === index, onClick: () => setOpenCluster(openCluster === index ? null : index), children: cluster.band === "above" ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "pin-label", style: { borderColor: "var(--accent)", color: "var(--accent)" }, children: [cluster.ids.length, " resets"] }), _jsx("span", { className: "pin-stem" }), _jsx("span", { className: "pin-dot" })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-dot" }), _jsx("span", { className: "pin-stem" }), _jsxs("span", { className: "pin-label", style: { borderColor: "var(--accent)", color: "var(--accent)" }, children: [cluster.ids.length, " resets"] })] })) }, cluster.ids.join("+")));
320
+ }, "aria-label": `${cluster.ids.length} resets at the same time: ${cluster.ids.map((id) => byId.get(id)?.displayName ?? id).join(", ")}`, "aria-expanded": openCluster === index, onClick: () => setOpenCluster(openCluster === index ? null : index), children: cluster.band === "above" ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "pin-label", style: { borderColor: "var(--accent)", color: "var(--accent)", ...dodgeX }, children: [cluster.ids.length, " resets"] }), _jsx(PinStem, { band: cluster.band, offsetPx: cluster.offsetPx }), _jsx("span", { className: "pin-dot" })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-dot" }), _jsx(PinStem, { band: cluster.band, offsetPx: cluster.offsetPx }), _jsxs("span", { className: "pin-label", style: { borderColor: "var(--accent)", color: "var(--accent)", ...dodgeX }, children: [cluster.ids.length, " resets"] })] })) }, cluster.ids.join("+")));
189
321
  }), openCluster !== null && placement.clusters[openCluster] && (_jsx(ClusterPopover, { cluster: placement.clusters[openCluster], providers: byId, asOfMs: asOfMs, onSelect: onSelectProvider, onClose: () => setOpenCluster(null) })), _jsx("div", { className: "rail-days", "aria-hidden": "true", children: labels.map((label) => (_jsx("span", { children: label }, label))) })] }) })] }));
190
322
  }