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
14 changes: 14 additions & 0 deletions .github/changes/v1.0.5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## v1.0.5 更新内容

### 改进

- **紧凑用量卡片**:用量页每个账号的头部压缩为一行(图标、名称、套餐、状态);每个额度只占一行:名称、进度条、已用百分比和重置倒计时。同样的屏幕可以看到更多服务商。剩余额度和完整重置时间仍在详情页显示。
- **节奏线**:进度条上的细竖线表示「按平均速度此刻应用到的位置」,进度超过竖线说明用得比平均快。适用于 5 小时、每日、每周和每月窗口。
- **大字体自动换行**:系统字体放大到 1.3 倍及以上时,每个额度自动改为三行显示,保证可读性。
- **套餐到期提示**:卡片上只在到期前 7 天内显示,其余时间在详情页查看。
- **全局重置雷达**:Codex 详情页的预测、事件和服务状态合并为一张卡。先给出未来 24 小时全局重置的可能性(较低 / 有可能 / 较高)和百分比,再展示 48 小时概率、置信度、标出你自己周额度重置时间的时间线、近 30 天重置记录和平均间隔。历史和故障信息可展开查看,数据来源只显示一次。
- **用量页重置提示**:开启全局重置雷达后,若未来 24 小时全局重置的可能性达到 15% 及以上,或出现官方信号,Codex 卡片会显示「全局重置 N%」或「全局重置信号」标签。该标签不影响账号状态和提醒。

### 使用说明

- 全局重置数据来自独立第三方 codex-reset.com,默认关闭,开启前需确认;预测不保证你的个人额度恢复。
2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"expo": {
"name": "AI Usage",
"slug": "usage",
"version": "1.0.4",
"version": "1.0.5",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "usage",
Expand Down
43 changes: 31 additions & 12 deletions docs/codex-reset.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,40 @@ controlled response variations, not a claim that a live outage was observed.
- Public 401/403 is a data-source failure, never a rejected ChatGPT credential. Public data
failure is never evidence of an OpenAI outage. Stale status is explicitly labelled as old data.

Each of the three data sections displays **Data: codex-reset.com**, linked to
<https://codex-reset.com/> on that same surface. README and this document carry the credit too.
Do not replace the on-screen credit with an About-page link. No data is republished via an API.
The radar card displays **Data: codex-reset.com** once, linked to <https://codex-reset.com/>,
next to the source update time; it covers forecast, history and status on that same surface.
README and this document carry the credit too. Do not replace the on-screen credit with an
About-page link. No data is republished via an API.

## UI and alerts

The module reuses `Section`, `ToggleRow`, `Button`, theme colors and existing motion.
Information is displayed in one column, including account headings and shared personal meters.
Forecast percentages use theme-accent text, not personal quota bars. Personal meters keep their
existing calculation and animation with used amount, bar, remaining amount and reset stacked.
Confirmed history defaults to two rows; other announcements appear only when expanded. Expansion is bounded to
ten confirmed and five other rows; the complete history remains available at the source.
Operational status is compact; recent incidents are expandable. All interface copy is English
and Chinese; `Intl.DateTimeFormat` follows the app language and device timezone. Today/yesterday
use local calendar dates, with years shown for older-year events.
Everything lives in one "Global reset radar" section on the Codex details screen, built from
`Section`, `Button`, theme colors and existing motion. It answers first and shows evidence after:

- Status chips: Codex operational / incident / unknown, plus "Outdated data" when any endpoint
is stale. An incident adds the line that it differs from exhausted personal quota.
- The 24-hour chance as a large accent number with a word band (`chanceBand`: under 15% low,
15-39% possible, 40% and up high), then the 48-hour chance and confidence as 1-3 dots plus text.
Low confidence keeps its explicit warning.
- A Now / 24h / 48h strip shaded by the two source odds. When the account's own `weekly` meter
has a future reset, a marker sits at its position (pinned to the end with an arrow past 48 h),
and a sentence states its used share and countdown. Odds are never extrapolated to that time.
- A 30-day row of local calendar days: filled for a confirmed reset, outlined for an unconfirmed
`reset` signal; other groups are not marked. Last confirmed reset (relative and absolute) and
the average interval between confirmed resets (needs two) follow.
- "History and service status" expands up to ten confirmed and five other announcements and
three incidents; the complete history remains available at the source.
- One footnote: fetch error (once for the card), stale warning, source time and credit, and the
"Turn off" link. Off, the section is a short explanation and a "Turn on" button that opens the
same consent dialog.

On the overview, a Codex card shows a "Global reset N%" chip only after opt-in, with fresh
forecast data, and when the 24-hour band is possible or higher; an official signal shows
"Global reset signal". It polls under the same focus/foreground gates and cache as the details
screen and never changes the account's status or alerts.

All interface copy is English and Chinese; `Intl.DateTimeFormat` follows the app language and
device timezone. Today/yesterday use local calendar dates, with years shown for older-year events.

Global reset notifications are intentionally deferred. Existing alerts are account/meter-cycle
based. A global subscription needs a persistent first-enable baseline, event-ID deduplication
Expand Down
7 changes: 6 additions & 1 deletion src/app/account/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,12 @@ export default function AccountDetailScreen() {
</Section>
)}

{v.account.providerId === 'codex' ? <CodexResetSection now={now} /> : null}
{v.account.providerId === 'codex' ? (
<CodexResetSection
now={now}
weekly={meters.find((m) => m.id === 'weekly' && m.scope.type === 'overall')}
/>
) : null}

<SubscriptionSection
key={`${accountId}:${v.price?.amount ?? ''}:${v.price?.currency ?? ''}`}
Expand Down
21 changes: 21 additions & 0 deletions src/core/__tests__/meter-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
countdown,
cycleKey,
elapsedFraction,
level,
mostConstrained,
remaining,
Expand Down Expand Up @@ -81,3 +82,23 @@ describe('mostConstrained / cycleKey', () => {
);
});
});

describe('elapsedFraction', () => {
const now = new Date('2026-10-06T12:00:00Z');
it('places the pace tick by the known window length of shared meter ids', () => {
// 5-hour window resetting in 2 h: 3 of 5 hours gone
expect(elapsedFraction(pct('session', 10, '2026-10-06T14:00:00Z'), now)).toBeCloseTo(0.6);
// weekly window resetting in 3.5 days: half gone
expect(elapsedFraction(pct('weekly', 10, '2026-10-10T00:00:00Z'), now)).toBeCloseTo(0.5);
// calendar month: 10 Sep -> 10 Oct, 26 of 30 days gone
expect(elapsedFraction(pct('monthly', 10, '2026-10-10T12:00:00Z'), now)).toBeCloseTo(26 / 30);
});

it('draws nothing without a known window or a future reset', () => {
expect(elapsedFraction(pct('points', 10, '2026-10-07T00:00:00Z'), now)).toBeUndefined();
expect(elapsedFraction(pct('session', 10), now)).toBeUndefined();
expect(elapsedFraction(pct('session', 10, '2026-10-06T11:00:00Z'), now)).toBeUndefined();
// a reset further away than the window means the guess is wrong: no tick
expect(elapsedFraction(pct('session', 10, '2026-10-07T12:00:00Z'), now)).toBeUndefined();
});
});
30 changes: 30 additions & 0 deletions src/core/meter-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,33 @@ export function cycleKey(accountId: string, m: Meter): string {
const scope = m.scope.type === 'model' ? `:${m.scope.name}` : '';
return `${accountId}:${m.id}${scope}:${m.resetsAt ?? 'none'}`;
}

const HOUR = 3_600_000;
/** Window length by the meter ids providers share; calendar-month windows are handled apart. */
const FIXED_WINDOWS: Record<string, number> = {
session: 5 * HOUR,
weekly: 7 * 24 * HOUR,
usage_weekly: 7 * 24 * HOUR,
today: 24 * HOUR,
usage_daily: 24 * HOUR,
};
const MONTHLY = new Set(['monthly', 'month', 'usage_monthly', 'premium', 'chat', 'completions']);

/**
* How far through its window a meter is, 0..1: where an even pace would have the bar now.
* Undefined when the window length is not known for this meter id or the reset is missing/past.
*/
export function elapsedFraction(m: Meter, now: Date): number | undefined {
const end = m.resetsAt ? Date.parse(m.resetsAt) : NaN;
if (!Number.isFinite(end) || end <= now.getTime()) return undefined;
let start: number;
const fixed = FIXED_WINDOWS[m.id];
if (fixed) start = end - fixed;
else if (MONTHLY.has(m.id)) {
const d = new Date(end);
d.setMonth(d.getMonth() - 1);
start = d.getTime();
} else return undefined;
const f = (now.getTime() - start) / (end - start);
return f >= 0 && f <= 1 ? f : undefined;
}
60 changes: 60 additions & 0 deletions src/data/codex-reset/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,63 @@ export function errorText(error: DataError | undefined, t: Translator): string {
if (error === 'invalid') return t('Codex Reset returned unrecognized data.');
return t('Codex Reset data unavailable. This does not indicate a Codex outage.');
}

export type ChanceBand = 'low' | 'possible' | 'high';

/** Words before numbers: rounded percentage (0..100) to a band. */
export function chanceBand(percent: number | null | undefined): ChanceBand | undefined {
if (percent == null || !Number.isFinite(percent)) return undefined;
if (percent >= 40) return 'high';
if (percent >= 15) return 'possible';
return 'low';
}

export function chanceBandText(band: ChanceBand | undefined, t: Translator): string {
if (band === 'high') return t('High');
if (band === 'possible') return t('Possible');
if (band === 'low') return t('Low');
return t('Unknown');
}

/** Confidence as filled dots out of three; undefined when the source gives no known level. */
export function confidenceLevel(value: string | null | undefined): 1 | 2 | 3 | undefined {
if (value === 'low') return 1;
if (value === 'medium') return 2;
if (value === 'high') return 3;
return undefined;
}

/** Mean days between consecutive confirmed resets; needs at least two. */
export function averageIntervalDays(confirmed: ResetEvent[]): number | undefined {
const times = confirmed
.map((e) => Date.parse(e.announced_at))
.filter(Number.isFinite)
.sort((a, b) => a - b);
if (times.length < 2) return undefined;
const span = times[times.length - 1] - times[0];
return Math.max(1, Math.round(span / (times.length - 1) / 86_400_000));
}

export type DayMark = 'confirmed' | 'signal' | undefined;

/**
* One mark per local calendar day, oldest first, ending today. A confirmed reset outranks an
* unconfirmed reset signal on the same day; other announcement groups are not marked.
*/
export function resetDays(events: ResetEvent[], now: Date, days = 30): DayMark[] {
const marks: DayMark[] = Array.from({ length: days }, () => undefined);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
for (const e of events) {
if (e.group !== 'reset') continue;
const at = new Date(Date.parse(e.announced_at));
if (!Number.isFinite(at.getTime())) continue;
const day = new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime();
// calendar days, rounded so a daylight-saving shift does not drop a day
const back = Math.round((today - day) / 86_400_000);
if (back < 0 || back >= days) continue;
const i = days - 1 - back;
if (isConfirmedReset(e)) marks[i] = 'confirmed';
else if (marks[i] !== 'confirmed') marks[i] = 'signal';
}
return marks;
}
34 changes: 26 additions & 8 deletions src/data/codex-reset/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export function resetQuery(service: CodexResetService, endpoint: Endpoint) {
};
}

export function useCodexReset() {
/** Polls only while the screen is focused and the app is in the foreground, and only after opt-in. */
function usePollOptions() {
const service = useServices().data?.codexReset;
const preference = useCodexResetPreference();
const [focused, setFocused] = useState(false);
Expand All @@ -35,14 +36,21 @@ export function useCodexReset() {
const sub = AppState.addEventListener('change', (state) => setActive(state === 'active'));
return () => sub.remove();
}, []);
const options = {
enabled: !!service && preference.enabled && focused && active,
staleTime: CACHE_MS,
gcTime: 24 * 60 * 60_000,
retry: false as const,
refetchInterval: CACHE_MS,
refetchIntervalInBackground: false,
return {
service,
options: {
enabled: !!service && preference.enabled && focused && active,
staleTime: CACHE_MS,
gcTime: 24 * 60 * 60_000,
retry: false as const,
refetchInterval: CACHE_MS,
refetchIntervalInBackground: false,
},
};
}

export function useCodexReset() {
const { service, options } = usePollOptions();
return {
forecast: useQuery({
...options,
Expand All @@ -62,6 +70,16 @@ export function useCodexReset() {
};
}

/** Forecast alone, for the overview card; shares the cache and gates of the details screen. */
export function useCodexResetForecast() {
const { service, options } = usePollOptions();
return useQuery({
...options,
queryKey: resetKey('forecast'),
queryFn: () => service!.load('forecast'),
});
}

/** fetchQuery honours the same cache as mount/poll; the service also gates background reads. */
export function useRefreshCodexReset() {
const service = useServices().data?.codexReset;
Expand Down
41 changes: 31 additions & 10 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,37 @@
// A test enforces coverage.
export const zh: Record<string, string> = {
// Codex Reset public data
'Show global reset forecast': '显示全局重置预测',
'Also shows public reset history and service status. Applies to all Codex accounts.':
'同时显示公共重置历史和服务状态,对所有 Codex 账号生效。',
'Off. No requests are sent to codex-reset.com.': '已关闭,不会向 codex-reset.com 发起请求。',
'Global reset radar': '全局重置雷达',
"See how likely OpenAI is to reset everyone's Codex limits early. Data comes from codex-reset.com, an independent site; you confirm before anything is requested.":
'查看 OpenAI 为所有人提前重置 Codex 额度的可能性。数据来自独立网站 codex-reset.com,发起任何请求前都会先征得你的同意。',
'Turn on': '开启',
'Turn off global reset radar': '关闭全局重置雷达',
'Chance of a global reset in the next 24 hours': '未来 24 小时发生全局重置的可能性',
Low: '较低',
Possible: '有可能',
High: '较高',
'48 hours: {percent}': '48 小时:{percent}',
Now: '现在',
'▲ Your weekly reset': '▲ 你的周额度重置',
'Your weekly reset is later →': '你的周额度重置更晚 →',
'Your weekly quota resets on its own in {time}.': '你的周额度将在 {time}后自行重置。',
'Your weekly quota is {used}% used and resets on its own in {time}.':
'你的周额度已用 {used}%,将在 {time}后自行重置。',
'Last 30 days': '近 30 天',
'{confirmed} confirmed · {signals} signals': '已确认 {confirmed} 天 · 信号 {signals} 天',
'Last 30 days: {confirmed} days with a confirmed reset, {signals} with an unconfirmed signal':
'近 30 天:{confirmed} 天有已确认重置,{signals} 天有未确认信号',
'Average interval': '平均间隔',
'about {n} days': '约 {n} 天',
'History and service status': '历史与服务状态',
'Outdated data': '数据已过期',
'Global reset {percent}%': '全局重置 {percent}%',
'Global reset signal': '全局重置信号',
'Global reset signal reported, not confirmed': '已报告全局重置信号,尚未确认',
'Global reset chance in 24 hours: {percent}%': '24 小时内全局重置的可能性:{percent}%',
// compact meter rows
Due: '待重置',
'Plan ends · {left}': '方案到期 · {left}',
'About the data source': '了解数据来源',
'Enable Codex Reset?': '开启 Codex Reset?',
'Forecasts, global events and service status are fetched directly from codex-reset.com, an independent service, not OpenAI. Your API keys, ChatGPT access tokens, account IDs and personal usage are never sent to this site. Predictions do not guarantee your personal quota will recover. Allow public requests while this switch is on, including background refresh when available?':
Expand All @@ -17,8 +44,6 @@ export const zh: Record<string, string> = {
'View status report': '查看故障报告',
'Today {time}': '今天 {time}',
'Yesterday {time}': '昨天 {time}',
'Global reset forecast': '全局重置预测',
'Extra reset chance in {hours} hours': '未来 {hours} 小时额外重置概率',
Probability: '概率',
Unknown: '未知',
'Low confidence': '低置信度',
Expand All @@ -31,7 +56,6 @@ export const zh: Record<string, string> = {
'A signal is not a confirmed reset.': '信号不代表重置已确认发生。',
'Independent prediction, not an OpenAI quota API. A global reset does not guarantee your personal quota will recover.':
'独立第三方预测,并非 OpenAI 官方额度接口。全局重置不保证你的个人额度恢复。',
'Recent global events': '近期全局事件',
'Unconfirmed signals and other announcements are not confirmed global resets.':
'未确认信号和其他公告不代表已确认的全局重置。',
'Confirmed reset': '已确认重置',
Expand All @@ -42,15 +66,12 @@ export const zh: Record<string, string> = {
'Other announcement': '其他公告',
'No confirmed resets reported.': '暂无已确认的重置记录。',
'View announcement': '查看公告',
'More history': '更多历史记录',
'Show less': '收起',
'Codex service status': 'Codex 服务状态',
'Codex operational': 'Codex 运行正常',
'Codex service incident reported': '来源报告了 Codex 服务故障',
'Codex status unknown': 'Codex 状态未知',
'A platform incident is different from exhausted personal quota.':
'平台服务故障与个人额度耗尽是不同的问题。',
'Recent incidents': '近期故障',
'Ongoing incident': '故障处理中',
'Status unknown': '状态未知',
Resolved: '已恢复',
Expand Down
Loading
Loading