8000 fix(quota): restart estimates at the upstream reset after a plan upgrade for 0.9.11 by InfinityPacer · Pull Request #39 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## [未发布]

## [0.9.11] - 2026-10-14

- 升级 Claude 套餐后,7 天与 Fable 额度预估从上游清零的时刻重新起算,新套餐的预估更早出现,初期也不再明显偏低。
- 重置时间按最接近的整分钟显示,同一重置点不再在 15:59 和 16:00 之间跳动。
- 手机额度卡片在 5h 额度从「等待更新」恢复后,重置时间不再超出卡片右边。
- 数据库结构未变,升级无需先备份。

## [0.9.10] - 2026-10-14

- 手机首页额度卡片里的重置时间始终排成一行,5h 额度刚重置时「16:20 已重置」不再折成两行。
Expand Down
2 changes: 1 addition & 1 deletion docs/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@

本周期观测较少时,只在同一来源、同一已知套餐的连续观测区间内参考上一周期。有足够的多段观测后随当前观测跨度增加逐步调整,跨度达到 20 个百分点后完全使用本周期数据。上一周期也没有足够多段观测时,只有已观测到至少 10% 才把其加权粗估作为参考。没有可用历史时,按本周期不同百分比的累计消费加权粗估,较高百分比权重更大。账户详情说明估算依据。周期重置后重新积累当前样本,不参考久远周期。

服务端发现套餐变化后立即停止参考旧套餐,只用之后的新快照计算消费和百分比增量;百分比大幅回退(至少 5 个百分点且不高于此前最高值一半)也会重新取基准。刚发生变化、还没有两个可比较的新点时暂时无法估算,有有效增量后恢复粗估。周期累计用量仍保留变化前后的全部消费,已花金额也包含变化前的消费,所以该周期的预估偏高,下个周期恢复正常。套餐观测记录从升级后开始积累,不把当前套餐补写到旧快照,也不把服务端发现时间当作上游实际生效时间。上游没有提供套餐信息或套餐标签不变的额度调整,无法保证即时识别。
服务端发现套餐变化后立即停止参考旧套餐,只用之后的新快照计算消费和百分比增量;百分比大幅回退(至少 5 个百分点且不高于此前最高值一半)也会重新取基准。Claude Code 只在打开 `/usage` 等刷新账户资料时更新套餐,升级时上游通常已先把百分比清零,这次清零就作为新套餐的起点,清零后首个百分比读数即可开始粗估。没有清零、还没有两个可比较的新点时暂时无法估算,有有效增量后恢复粗估。周期累计用量仍保留变化前后的全部消费,已花金额也包含变化前的消费,所以该周期的预估偏高,下个周期恢复正常。套餐观测记录从升级后开始积累,不把当前套餐补写到旧快照,也不把服务端发现时间当作上游实际生效时间。上游没有提供套餐信息或套餐标签不变的额度调整,无法保证即时识别。

手机上预估跟在已用费用之后,桌面账户列表在右侧单独显示。缺少有效比例、可计价消费或窗口失效时保留未知,不补零。快照可能包含其他入口的消费,本地请求只覆盖已采集来源,模型组合和供应商计量规则也可能变化,因此估值不是供应商承诺额度。

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.9.10",
"version": "0.9.11",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
55 changes: 39 additions & 16 deletions src/domain/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,34 +225,56 @@ export function quotaView(
at: Date.parse(sample.sampledAt ?? ""),
cycle: cycleResetKey(sample.resetsAt),
}));
const plan = [...(latest.planHistory ?? [])]
const plans = [...(latest.planHistory ?? [])]
.filter((entry) => Date.parse(entry.observedAt) <= time)
.sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt))
.at(-1);
.sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt));
const plan = plans.at(-1);
const planStart = plan ? Date.parse(plan.observedAt) : NaN;
const priorPlanStart =
plans.length > 1 ? Date.parse(plans.at(-2)!.observedAt) : -Infinity;
const duration = (fact.windowMinutes ?? 0) * 60_000;
/** 返回某周期内同一来源、同一已知套餐区间的有效读数,过时回落已剔除。 */
const cycleReadings = (cycleReset: number, until: number) => {
const cycleStart = cycleReset - duration;
// 已发现套餐变化后不能使用变化前的点。初次建立历史不冒充过去的套餐证明。
const boundary =
plan && (plan.changed || cycleReset !== reset)
? Math.max(cycleStart, planStart)
: cycleStart;
const observations = sameSource.flatMap(({ sample, at, cycle }) => {
/** 周期内同一来源的有效读数,过时回落、负值和缺值已剔除。 */
const readingsBetween = (cycleReset: number, from: number, until: number) =>
sameSource.flatMap(({ sample, at, cycle }) => {
if (
cycle !== cycleReset ||
sample.percent === null ||
!Number.isFinite(sample.percent) ||
sample.percent < 0 ||
!Number.isFinite(at) ||
at < boundary ||
at < from ||
at > until ||
at >= cycleReset
)
return [];
return [{ percent: sample.percent, sampledAt: sample.sampledAt! }];
return [{ percent: sample.percent, sampledAt: sample.sampledAt!, at }];
});
/** 返回某周期内同一来源、同一已知套餐区间的有效读数,过时回落已剔除。 */
const cycleReadings = (cycleReset: number, until: number) => {
const cycleStart = cycleReset - duration;
// 已发现套餐变化后不能使用变化前的点。初次建立历史不冒充过去的套餐证明。
let boundary =
plan && (plan.changed || cycleReset !== reset)
? Math.max(cycleStart, planStart)
: cycleStart;
// 套餐要等 Claude Code 刷新账户资料才上报,上游往往更早已按新套餐重新计数。
// 观察到变化前、上一次套餐观察后的最后一次重新计数就是新套餐的起点,
// 从这里起算才能保留清零读数,使百分比与费用都从真实起点累计。
if (plan?.changed && boundary > cycleStart) {
const lead = stableReadings(
readingsBetween(
cycleReset,
Math.max(cycleStart, priorPlanStart),
boundary,
),
);
const restart = lead.readings[0];
if (lead.rebased && restart && restart.at < boundary)
boundary = restart.at;
}
const observations = readingsBetween(cycleReset, boundary, until).map(
({ percent, sampledAt }) => ({ percent, sampledAt }),
);
const stable = stableReadings(observations);
return {
observations: stable.readings,
Expand Down Expand Up @@ -336,8 +358,9 @@ export function quotaView(
percent: new Decimal(entry.percent).sub(baseline.percent).toNumber(),
}))
: observations;
// 上游百分比为四舍五入整数。重新起算的基准点落在平台期中段,相对百分比的取整偏差无法确定,不做校正。
const roundedPercent = !rebased;
// 上游百分比为四舍五入整数。重新起算的基准点落在平台期中段时,相对百分比的取整偏差无法确定,
// 不做校正;基准点是清零后的 0%,相对百分比就是上游读数,仍按首次到达校正。
const roundedPercent = !baseline || baseline.percent === 0;
const segments = estimateQuotaSegments(
estimateObservations,
estimateRead,
Expand Down
20 changes: 14 additions & 6 deletions src/web/App.tsx
< 8BD0 td class="blob-num blob-num-addition empty-cell">
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
quotaLabel,
quotaPercent,
quotaState,
quotaResetMinute,
showQuotaEstimate,
visibleQuotaWindows,
withFableQuotaWindow,
Expand Down Expand Up @@ -2197,11 +2198,16 @@ export function App() {
"expired"
? "N/A"
: selectedAccount.sevenDay.resetsAt
? localTime(selectedAccount.sevenDay.resetsAt, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
? localTime(
quotaResetMinute(
selectedAccount.sevenDay.resetsAt,
),
{
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
)
: "N/A"}
</dd>
{visibleQuotaWindows(
Expand Down Expand Up @@ -2286,7 +2292,9 @@ export function App() {
<dd>
{selectedAccount.sevenDayFable.resetsAt
? localTime(
selectedAccount.sevenDayFable.resetsAt,
quotaResetMinute(
selectedAccount.sevenDayFable.resetsAt,
),
{
hour: "2-digit",
minute: "2-digit",
Expand Down
6 changes: 4 additions & 2 deletions src/web/components/QuotaWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
estimateDescription,
quotaLabel,
quotaPercent,
quotaResetMinute,
quotaWaitingReset,
showQuotaEstimate,
type VisibleQuotaWindow,
Expand Down Expand Up @@ -34,9 +35,10 @@ const dayKeyFormatter = new Intl.DateTimeFormat("en-CA", {

/** 当天重置只显示时刻,跨天才带日期;无效时间不猜测。 */
function formatResetTime(value: string | null, asOf: string) {
const reset = value ? new Date(value) : null;
const minute = quotaResetMinute(value);
const sample = new Date(asOf);
if (!reset || !Number.isFinite(reset.getTime())) return "重置未知";
if (minute === null) return "重置未知";
const reset = new Date(minute);
return Number.isFinite(sample.getTime()) &&
dayKeyFormatter.format(reset) === dayKeyFormatter.format(sample)
? `${clockFormatter.format(reset)} 重置`
Expand Down
7 changes: 5 additions & 2 deletions src/web/components/quota-window.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@
border-top: 1px solid var(--line);
}

/* 用 flex 而非 auto 列:iOS Safari 在重置文字变长后不一定重算 auto 列宽,时间会溢出卡片。 */
.quota-window-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 7px;
min-width: 0;
}

.quota-window-title {
flex: 1 1 auto;
display: flex;
align-items: baseline;
flex-wrap: wrap;
Expand Down Expand Up @@ -59,6 +61,7 @@

/* 重置时间是一个整体,不在中间断行;窄屏时由左侧标题让出宽度。 */
.quota-window-reset {
flex: none;
white-space: nowrap;
color: var(--muted);
font-size: 12px;
Expand Down
14 changes: 12 additions & 2 deletions src/web/lib/quota-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,25 @@ export function withFableQuotaWindow(
];
}

/**
* 展示用的重置时刻,取最接近的整分钟。上游同一重置点会在整点前后差不到一秒,
* 直接按分钟截断会在 15:59 与 16:00 之间来回变化;无效时间返回 null。
*/
export function quotaResetMinute(resetsAt: string | null | undefined) {
const value = Date.parse(resetsAt ?? "");
return Number.isFinite(value) ? Math.round(value / 60_000) * 60_000 : null;
}

/** 等待新采样的窗口只说明上一周期何时结束,不给出百分比或金额。 */
export function quotaWaitingReset(window: AccountWindow, asOf: string) {
if (quotaState(window, asOf) !== "expired" || !window.resetsAt) return null;
const reset = quotaResetMinute(window.resetsAt);
if (quotaState(window, asOf) !== "expired" || reset === null) return null;
return `${new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(window.resetsAt))} 已重置`;
}).format(reset)} 已重置`;
}

/** 各入口使用同一有效周期规则,过期的耗尽状态不能延续到下一周期。 */
Expand Down
12 changes: 12 additions & 0 deletions tests/quota-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
quotaWaitingReset,
accountQuotaExhausted,
withFableQuotaWindow,
quotaResetMinute,
} from "../src/web/lib/quota-display";

type QuotaEstimate = NonNullable<AccountWindow["estimate"]>;
Expand Down Expand Up @@ -250,3 +251,14 @@ test("quota timer selects the earliest pending window and ignores unknown dates"
),
).toBe(2_147_483_647);
});

test("reset times round to the nearest minute so sub-second jitter shows one value", () => {
expect(quotaResetMinute("2026-10-18T07:59:59.568Z")).toBe(
Date.parse("2026-10-18T08:00:00.000Z"),
);
expect(quotaResetMinute("2026-10-18T08:00:00Z")).toBe(
Date.parse("2026-10-18T08:00:00.000Z"),
);
expect(quotaResetMinute(null)).toBeNull();
expect(quotaResetMinute("invalid")).toBeNull();
});
43 changes: 43 additions & 0 deletions tests/quota-plan-estimate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,46 @@ test("estimates add the remaining share to actual spend and converge to it when
const full = quotaView(exhausted, reader, iso(8, 10))!;
expect(full.estimate.usd).toBe(full.periodUsd);
});

test("plan upgrade reported after the upstream reset starts from the zero reading", () => {
// 升级后上游当即清零,账户资料要等 Claude Code 刷新后才上报新套餐。
const plans = [
...known,
{ plan: "max", observedAt: iso(8, 2), changed: true },
];
const spend = [900, 900, 900, 920, 960];
const reader: QuotaChargeReader = (_, end) => ({
usd: String(spend[Math.min(new Date(end).getUTCHours(), 4)]),
credits: null,
});
const readings = [sample(8, 0, 90), sample(8, 1, 0), sample(8, 3, 1)].map(
(s) => ({ ...s, planHistory: plans }),
);
const result = quotaView(readings, reader, iso(8, 3))!;
expect(result.percent).toBe(1);
// 首次到达 1% 实际约为 0.5%,清零后花费 20 对应整周 4000。
expect(fullWeekEstimate(result)).toBe("4000");
expect(result.estimate.methods?.usd).toBe("rough");
});

test("a reset before the previous plan observation is not the new plan's start", () => {
const plans = [
...known,
{ plan: "max", observedAt: iso(8, 2), changed: true },
{ plan: "pro", observedAt: iso(8, 4), changed: true },
];
const spend = [900, 900, 900, 920, 960, 1000];
const reader: QuotaChargeReader = (_, end) => ({
usd: String(spend[Math.min(new Date(end).getUTCHours(), 5)]),
credits: null,
});
const readings = [
sample(8, 0, 90),
sample(8, 1, 0),
sample(8, 3, 1),
sample(8, 5, 2),
].map((s) => ({ ...s, planHistory: plans }));
const result = quotaView(readings, reader, iso(8, 5))!;
// 只有 hour 5 一个新套餐读数,不能借用 hour 1 的清零点外推。
expect(result.estimate.usd).toBeNull();
});
Loading
0