quotacap 0.0.30 → 0.0.31
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/dist/adapters/codex.js +7 -1
- package/dist/version.js +1 -1
- package/dist/web/src/App.js +14 -1
- package/dist/web/src/components/PaceBar.d.ts +14 -4
- package/dist/web/src/components/PaceBar.js +18 -11
- package/dist/web/src/components/ProviderCard.js +3 -2
- package/dist/web/src/components/ProviderDrawer.js +3 -3
- package/dist/web/src/components/ProviderRow.d.ts +1 -1
- package/dist/web/src/components/ProviderRow.js +6 -2
- package/dist/web/src/state.d.ts +9 -0
- package/dist/web/src/state.js +26 -0
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +1 -1
- package/web/dist/assets/{index-BRBtLIOV.css → index-CjNVp2Yx.css} +1 -1
- package/web/dist/assets/index-hA7pT5M5.js +12 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DnWV59MD.js +0 -12
package/dist/adapters/codex.js
CHANGED
|
@@ -107,10 +107,16 @@ export function parseCodexTui(text, now = new Date()) {
|
|
|
107
107
|
fiveIso = new Date(now.getTime() + 5 * 3600000).toISOString();
|
|
108
108
|
const usedPct = 100 - weeklyLeft;
|
|
109
109
|
const sessionPct = 100 - fiveLeft;
|
|
110
|
+
// The /status panel carries "Account: <email> (<plan>)" above the limits.
|
|
111
|
+
// Vendor casing preserved (grok/muse precedent); unknown when absent.
|
|
112
|
+
let plan = "unknown";
|
|
113
|
+
const account = cleaned.match(/Account:\s*\S+\s*\(([^)]+)\)/i);
|
|
114
|
+
if (account && account[1].trim())
|
|
115
|
+
plan = account[1].trim();
|
|
110
116
|
const periodStart = new Date(new Date(weeklyIso).getTime() - 7 * 86400000).toISOString();
|
|
111
117
|
return {
|
|
112
118
|
provider: "codex",
|
|
113
|
-
plan
|
|
119
|
+
plan,
|
|
114
120
|
usedPct,
|
|
115
121
|
sessionPct,
|
|
116
122
|
resetsAt: weeklyIso,
|
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.
|
|
2
|
+
export const VERSION = "0.0.31";
|
package/dist/web/src/App.js
CHANGED
|
@@ -63,13 +63,26 @@ function App() {
|
|
|
63
63
|
useEffect(() => {
|
|
64
64
|
void load();
|
|
65
65
|
}, [load]);
|
|
66
|
+
// While the daemon reports a non-idle poll state, keep re-reading until it
|
|
67
|
+
// settles. Without this the pill ("Polling", "Cooling down") sticks forever
|
|
68
|
+
// on the single post-refresh snapshot, which is always mid-cooldown.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!snapshot || snapshot.runtime.polling === "idle")
|
|
71
|
+
return;
|
|
72
|
+
const t = setInterval(() => void load(), 5000);
|
|
73
|
+
return () => clearInterval(t);
|
|
74
|
+
}, [snapshot, load]);
|
|
66
75
|
const refresh = useCallback(async () => {
|
|
67
76
|
setRefreshing(true);
|
|
68
77
|
setNotice(null);
|
|
69
78
|
try {
|
|
70
79
|
const result = await triggerRefresh();
|
|
71
|
-
if (result.message)
|
|
80
|
+
if (result.message) {
|
|
72
81
|
setNotice(result.message);
|
|
82
|
+
}
|
|
83
|
+
else if (result.raw?.cooldown === true) {
|
|
84
|
+
setNotice("Refresh cooling down — showing last readings.");
|
|
85
|
+
}
|
|
73
86
|
await load();
|
|
74
87
|
}
|
|
75
88
|
catch (e) {
|
|
@@ -63,11 +63,21 @@ export declare function timeLeft(resetsAt: string, asOfMs: number): string | nul
|
|
|
63
63
|
* rows await a fresh window; invalid rows carry a row-level note.
|
|
64
64
|
*/
|
|
65
65
|
export declare function resetCountdown(provider: ProviderView, asOfMs: number): string;
|
|
66
|
+
export interface ResetCellLines {
|
|
67
|
+
/** Clock line ("Thu 16:53"), or the whole wording when single-line. */
|
|
68
|
+
top: string;
|
|
69
|
+
/** Countdown line ("in 2d 17h"), or null for single-line states. */
|
|
70
|
+
bottom: string | null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Reset cell for the table view as two stacked lines: the clock on top,
|
|
74
|
+
* the countdown below. Estimated resets mark the timestamp, per the 0.0.25
|
|
75
|
+
* convention. Reset-passed, missing, and unparseable rows stay single-line.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resetCellLines(provider: ProviderView, asOfMs: number): ResetCellLines;
|
|
66
78
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 0.0.25 convention. Falls back to the countdown-only wording for
|
|
70
|
-
* reset-passed, missing, and unparseable rows.
|
|
79
|
+
* Single-line reset wording, e.g. "Tue 10:25 · in 20h 30m". Same content
|
|
80
|
+
* as resetCellLines, joined for compact contexts.
|
|
71
81
|
*/
|
|
72
82
|
export declare function resetCell(provider: ProviderView, asOfMs: number): string;
|
|
73
83
|
export interface BarGeometry {
|
|
@@ -136,28 +136,35 @@ export function resetCountdown(provider, asOfMs) {
|
|
|
136
136
|
return `in ${left}${estimated}`;
|
|
137
137
|
}
|
|
138
138
|
/**
|
|
139
|
-
* Reset cell for the table view: clock
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* reset-passed, missing, and unparseable rows.
|
|
139
|
+
* Reset cell for the table view as two stacked lines: the clock on top,
|
|
140
|
+
* the countdown below. Estimated resets mark the timestamp, per the 0.0.25
|
|
141
|
+
* convention. Reset-passed, missing, and unparseable rows stay single-line.
|
|
143
142
|
*/
|
|
144
|
-
export function
|
|
143
|
+
export function resetCellLines(provider, asOfMs) {
|
|
145
144
|
if (provider.exclusionReason === "reset-passed")
|
|
146
|
-
return "awaiting fresh window";
|
|
145
|
+
return { top: "awaiting fresh window", bottom: null };
|
|
147
146
|
if (!provider.quota)
|
|
148
|
-
return "no readings";
|
|
147
|
+
return { top: "no readings", bottom: null };
|
|
149
148
|
const clock = resetClock(provider.quota.resetsAt);
|
|
150
149
|
const left = timeLeft(provider.quota.resetsAt, asOfMs);
|
|
151
150
|
const estimated = isEstimated(provider) ? " (est.)" : "";
|
|
152
151
|
if (clock && left)
|
|
153
|
-
return `${clock}${estimated}
|
|
152
|
+
return { top: `${clock}${estimated}`, bottom: `in ${left}` };
|
|
154
153
|
if (left === null) {
|
|
155
154
|
const resetMs = Date.parse(provider.quota.resetsAt);
|
|
156
155
|
if (Number.isFinite(resetMs) && resetMs <= asOfMs)
|
|
157
|
-
return "awaiting fresh window";
|
|
158
|
-
return "reset unknown";
|
|
156
|
+
return { top: "awaiting fresh window", bottom: null };
|
|
157
|
+
return { top: "reset unknown", bottom: null };
|
|
159
158
|
}
|
|
160
|
-
return "reset unknown";
|
|
159
|
+
return { top: "reset unknown", bottom: null };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Single-line reset wording, e.g. "Tue 10:25 · in 20h 30m". Same content
|
|
163
|
+
* as resetCellLines, joined for compact contexts.
|
|
164
|
+
*/
|
|
165
|
+
export function resetCell(provider, asOfMs) {
|
|
166
|
+
const { top, bottom } = resetCellLines(provider, asOfMs);
|
|
167
|
+
return bottom ? `${top} · ${bottom}` : top;
|
|
161
168
|
}
|
|
162
169
|
/** Bar geometry from server timestamps and percents. Presentation only. */
|
|
163
170
|
export function barGeometry(provider, asOfMs) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { ageDuration } from "../state.js";
|
|
2
|
+
import { ageDuration, displayPlan } from "../state.js";
|
|
3
3
|
import { Badge, PACE_TIPS, PaceBar, isEstimated, paceCells, resetClock, resetCountdown, timeLeft, } from "./PaceBar.js";
|
|
4
4
|
import { ProviderIcon, providerTint } from "./ProviderIcon.js";
|
|
5
5
|
function resetsDate(provider) {
|
|
@@ -18,10 +18,11 @@ export function ProviderCard({ provider, recommended, asOf, onSelect, }) {
|
|
|
18
18
|
const updated = ageDuration(provider.ageMs);
|
|
19
19
|
const resetsIn = quota ? timeLeft(quota.resetsAt, asOfMs) : null;
|
|
20
20
|
const name = provider.displayName;
|
|
21
|
+
const plan = displayPlan(quota?.plan);
|
|
21
22
|
return (_jsxs("article", { "data-testid": `provider-card-${provider.id}`, role: "button", tabIndex: 0, "aria-label": `${name} details`, onClick: () => onSelect(provider.id), onKeyDown: (e) => {
|
|
22
23
|
if (e.key === "Enter" || e.key === " ") {
|
|
23
24
|
e.preventDefault();
|
|
24
25
|
onSelect(provider.id);
|
|
25
26
|
}
|
|
26
|
-
}, className: `pcard ${recommended ? "is-recommended is-top-pick" : ""}`, children: [_jsxs("div", { className: "pcard-head", children: [_jsx("span", { className: "picon", style: providerTint(provider.id), "aria-hidden": "true", children: _jsx(ProviderIcon, { id: provider.id, title: name }) }), _jsxs("div", { className: "who", children: [_jsxs("b", { children: [name, recommended && (_jsx("span", { className: "rec-icon", title: "Recommended for next session", role: "img", "aria-label": "recommended", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.3", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z" }) }) }))] }),
|
|
27
|
+
}, className: `pcard ${recommended ? "is-recommended is-top-pick" : ""}`, children: [_jsxs("div", { className: "pcard-head", children: [_jsx("span", { className: "picon", style: providerTint(provider.id), "aria-hidden": "true", children: _jsx(ProviderIcon, { id: provider.id, title: name }) }), _jsxs("div", { className: "who", children: [_jsxs("b", { children: [name, recommended && (_jsx("span", { className: "rec-icon", title: "Recommended for next session", role: "img", "aria-label": "recommended", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.3", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z" }) }) }))] }), plan && _jsx("span", { className: "pl", children: plan }), updated && _jsxs("span", { className: "src", children: ["Updated ", updated, " ago"] })] }), _jsx(Badge, { provider: provider })] }), _jsxs("div", { className: "pcard-body", children: [_jsx(PaceBar, { provider: provider, asOf: asOf }), _jsxs("div", { className: "pstats", children: [_jsxs("div", { title: PACE_TIPS.avg, children: [_jsx("span", { className: "l", children: "Avg pace" }), _jsx("span", { className: "v", children: pace.avg })] }), _jsxs("div", { title: PACE_TIPS.recent, children: [_jsx("span", { className: "l", children: "24h pace" }), _jsx("span", { className: "v", children: pace.recent })] }), _jsxs("div", { title: PACE_TIPS.ideal, children: [_jsx("span", { className: "l", children: "Ideal pace" }), _jsx("span", { className: "v", children: advisory ? `${advisory.idealRate.toFixed(1)}%/day` : "—" })] }), _jsxs("div", { children: [_jsx("span", { className: "l", children: "Resets in" }), _jsx("span", { className: "v", children: resetsIn ? `${resetsIn}${isEstimated(provider) ? " (est.)" : ""}` : resetCountdown(provider, asOfMs) })] })] })] }), _jsxs("div", { className: "pcard-foot", children: [_jsx("span", { children: resetsDate(provider) ?? resetCountdown(provider, asOfMs) }), _jsx("span", { className: "rowbtn", "aria-hidden": "true", children: "Inspect" })] })] }));
|
|
27
28
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
|
-
import { ageDuration, providerSubtitle } from "../state.js";
|
|
3
|
+
import { ageDuration, displayPlan, providerSubtitle, shortWindowLabel } from "../state.js";
|
|
4
4
|
import { Badge, PaceBar, barGeometry, isEstimated, paceBadge, paceFigures, resetClock, timeLeft, } from "./PaceBar.js";
|
|
5
5
|
import { ProviderIcon, providerTint } from "./ProviderIcon.js";
|
|
6
6
|
import { renameProvider } from "../api.js";
|
|
@@ -298,7 +298,7 @@ export function ProviderDrawer({ provider, asOf, recommendation, onClose, onRena
|
|
|
298
298
|
const why = rankingCopy(provider, recommendation);
|
|
299
299
|
const record = compactSnapshot(provider, asOfMs);
|
|
300
300
|
const recordJson = JSON.stringify(record, null, 2);
|
|
301
|
-
const planBits = [provider.quota?.plan, sourceLabel(provider.quota?.source)].filter((b) => b && b !== "unknown");
|
|
301
|
+
const planBits = [displayPlan(provider.quota?.plan), sourceLabel(provider.quota?.source)].filter((b) => b && b !== "unknown");
|
|
302
302
|
const sub = planBits.join(" · ");
|
|
303
303
|
return (_jsxs(_Fragment, { children: [_jsx("div", { "data-testid": "drawer-scrim", className: "scrim is-open", "aria-hidden": "true", onClick: onClose }), _jsxs("aside", { ref: dialogRef, tabIndex: -1, "data-testid": "provider-drawer", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, className: "drawer is-open", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "drawer-head", children: [_jsx("span", { className: "picon", style: providerTint(provider.id), children: _jsx(ProviderIcon, { id: provider.id, title: model.name }) }), _jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [_jsxs("h2", { id: titleId, onClick: !isEditing ? () => setIsEditing(true) : undefined, title: !isEditing ? "Click to rename" : undefined, style: isEditing
|
|
304
304
|
? {
|
|
@@ -358,7 +358,7 @@ export function ProviderDrawer({ provider, asOf, recommendation, onClose, onRena
|
|
|
358
358
|
margin: "0 var(--s4) var(--s3)",
|
|
359
359
|
fontSize: "var(--t-2)",
|
|
360
360
|
color: "var(--ink-soft)",
|
|
361
|
-
}, children: provider.description })), _jsxs("section", { className: "dsec", "aria-label": "
|
|
361
|
+
}, children: provider.description })), _jsxs("section", { className: "dsec", "aria-label": "Weekly limit", children: [_jsx("h3", { children: "Weekly limit" }), _jsx("div", { className: "pcard-topline", children: _jsx(Badge, { provider: provider }) }), provider.quota ? (_jsxs(_Fragment, { children: [_jsx(PaceBar, { provider: provider, asOf: asOf }), underline && _jsx("div", { className: "pcard-underline", children: underline })] })) : (_jsx("p", { className: "cell-s", children: "No readings yet." }))] }), model.sessionPct !== undefined && model.sessionPct > 0 && (_jsxs("section", { className: "dsec", "aria-label": shortWindowLabel(provider.id) ?? "Session", children: [_jsx("h3", { children: shortWindowLabel(provider.id) ?? "Session" }), _jsx("div", { className: "pcard-topline", children: _jsxs("span", { className: "used", children: [model.sessionPct, "% used"] }) }), _jsx("div", { className: "track", role: "img", "aria-label": `${model.sessionPct} percent of ${(shortWindowLabel(provider.id) ?? "session").toLowerCase()} used`, children: _jsx("div", { className: "fill", style: {
|
|
362
362
|
width: `${Math.min(100, Math.max(0, model.sessionPct))}%`,
|
|
363
363
|
background: "var(--fill-ontrack)",
|
|
364
364
|
["--fill-edge"]: "var(--edge-ontrack)",
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { displayPlan, shortWindowLabel } from "../state.js";
|
|
3
|
+
import { Badge, PaceBar, forecastLine, paceLines, resetCellLines, } from "./PaceBar.js";
|
|
3
4
|
export function ProviderRow({ provider, recommended, asOf, onSelect, }) {
|
|
4
5
|
const quota = provider.quota;
|
|
5
6
|
const advisory = provider.advisory;
|
|
6
7
|
const asOfMs = Date.parse(asOf);
|
|
7
8
|
const lines = paceLines(advisory);
|
|
8
9
|
const name = provider.displayName;
|
|
9
|
-
|
|
10
|
+
const plan = displayPlan(quota?.plan);
|
|
11
|
+
const shortWindow = shortWindowLabel(provider.id);
|
|
12
|
+
const reset = resetCellLines(provider, asOfMs);
|
|
13
|
+
return (_jsxs("div", { "data-testid": `provider-row-${provider.id}`, className: `lrow ${recommended ? "is-recommended is-top-pick" : ""}`, onClick: () => onSelect(provider.id), children: [_jsxs("div", { className: "prov", "data-label": "Provider", children: [_jsx("span", { className: "dot", style: { background: `var(--p-${provider.id.split(":")[0]}, var(--surface-raised))` }, "aria-hidden": "true" }), _jsxs("span", { children: [_jsxs("span", { className: "nm", children: [name, recommended && (_jsx("span", { className: "rec-icon", title: "Recommended for next session", role: "img", "aria-label": "recommended", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.3", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z" }) }) }))] }), plan && _jsx("span", { className: "pl", children: plan }), quota?.sessionPct !== undefined && quota.sessionPct !== null && (_jsxs("span", { className: "src", children: [shortWindow ?? "Session", " \u00B7 ", quota.sessionPct, "% used"] }))] })] }), _jsx("div", { "data-label": "Used vs elapsed", children: quota ? _jsx(PaceBar, { provider: provider, asOf: asOf }) : "—" }), _jsxs("div", { "data-label": "Reset", className: "reset-cell", children: [_jsx("span", { className: "cell-b", children: reset.top }), reset.bottom && _jsx("span", { className: "cell-s", children: reset.bottom })] }), _jsxs("div", { "data-label": "Pace", children: [_jsx(Badge, { provider: provider }), lines.length > 0 && (_jsx("div", { style: { marginTop: 2 }, children: lines.map((l) => (_jsxs("div", { className: "cell-s paceline", title: l.tip, children: [_jsx("span", { children: l.label }), _jsx("span", { className: "cell-b", children: l.value })] }, l.label))) }))] }), _jsx("button", { type: "button", className: "rowbtn", onClick: (e) => {
|
|
10
14
|
e.stopPropagation();
|
|
11
15
|
onSelect(provider.id);
|
|
12
16
|
}, children: "Inspect" }), _jsx("span", { className: "sr-only", children: forecastLine(provider) })] }));
|
package/dist/web/src/state.d.ts
CHANGED
|
@@ -112,6 +112,15 @@ export declare function firstRun(s: {
|
|
|
112
112
|
* vendor shows. Null when neither is registered.
|
|
113
113
|
*/
|
|
114
114
|
export declare function providerSubtitle(p: Pick<ProviderView, "displayName" | "vendor" | "harness">): string | null;
|
|
115
|
+
/** Plan for display. Vendors that expose no tier report "unknown", which never renders. */
|
|
116
|
+
export declare function displayPlan(plan: string | null | undefined): string | null;
|
|
117
|
+
/**
|
|
118
|
+
* Short-window display name, mirroring each vendor's own /usage or /status
|
|
119
|
+
* wording: codex, kimi, and agy call it a "5h" or "Five Hour" limit, claude
|
|
120
|
+
* a "Current session" (a 5-hour rolling window), muse a "Current" window.
|
|
121
|
+
* Null when the provider reports no short window (grok, manual).
|
|
122
|
+
*/
|
|
123
|
+
export declare function shortWindowLabel(id: string): string | null;
|
|
115
124
|
/** "read Xm ago" from the last good snapshot's server timestamp. */
|
|
116
125
|
export declare function ageLabel(asOf: string, nowMs?: number): string;
|
|
117
126
|
/** Default poll interval; matches `pollMinutes` in `src/config.ts`. */
|
package/dist/web/src/state.js
CHANGED
|
@@ -44,6 +44,32 @@ export function providerSubtitle(p) {
|
|
|
44
44
|
return p.vendor;
|
|
45
45
|
return [p.harness, p.vendor].filter(Boolean).join(" · ");
|
|
46
46
|
}
|
|
47
|
+
/** Plan for display. Vendors that expose no tier report "unknown", which never renders. */
|
|
48
|
+
export function displayPlan(plan) {
|
|
49
|
+
if (!plan || plan === "unknown")
|
|
50
|
+
return null;
|
|
51
|
+
return plan;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Short-window display name, mirroring each vendor's own /usage or /status
|
|
55
|
+
* wording: codex, kimi, and agy call it a "5h" or "Five Hour" limit, claude
|
|
56
|
+
* a "Current session" (a 5-hour rolling window), muse a "Current" window.
|
|
57
|
+
* Null when the provider reports no short window (grok, manual).
|
|
58
|
+
*/
|
|
59
|
+
export function shortWindowLabel(id) {
|
|
60
|
+
switch (id.split(":")[0]) {
|
|
61
|
+
case "codex":
|
|
62
|
+
case "kimi":
|
|
63
|
+
case "agy":
|
|
64
|
+
return "5-hour limit";
|
|
65
|
+
case "claude":
|
|
66
|
+
return "Current session";
|
|
67
|
+
case "muse":
|
|
68
|
+
return "Current window";
|
|
69
|
+
default:
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
47
73
|
/** "read Xm ago" from the last good snapshot's server timestamp. */
|
|
48
74
|
export function ageLabel(asOf, nowMs = Date.now()) {
|
|
49
75
|
const diff = nowMs - Date.parse(asOf);
|