diff --git a/.github/changes/v1.0.5.md b/.github/changes/v1.0.5.md index f0bf36f..ffecbda 100644 --- a/.github/changes/v1.0.5.md +++ b/.github/changes/v1.0.5.md @@ -1,14 +1,25 @@ ## v1.0.5 更新内容 +### 新增 + +- **Codex 提前重置通知**:周额度在预定时间之前重新开始(例如 OpenAI 全局重置)时发送通知。只根据你自己的用量数据判断,不请求第三方。可在「设置 › 提醒 › Codex 提前重置时通知我」中关闭。 +- **用量页显示重置概率**:开启「重置概率」后,用量页用两行蓝色进度条显示「24H重置概率」和「48H重置概率」。只有一个 Codex 账号时显示在该卡片下;有多个时在订阅合计下显示一次。 +- **测试通知**:「设置 › 提醒」底部可发送一条测试通知,确认能否收到。 +- **用量页添加入口与更新时间**:标题栏新增「+」添加账号,标题下显示「x 分钟前更新」。 + ### 改进 -- **紧凑用量卡片**:用量页每个账号的头部压缩为一行(图标、名称、套餐、状态);每个额度只占一行:名称、进度条、已用百分比和重置倒计时。同样的屏幕可以看到更多服务商。剩余额度和完整重置时间仍在详情页显示。 -- **节奏线**:进度条上的细竖线表示「按平均速度此刻应用到的位置」,进度超过竖线说明用得比平均快。适用于 5 小时、每日、每周和每月窗口。 -- **大字体自动换行**:系统字体放大到 1.3 倍及以上时,每个额度自动改为三行显示,保证可读性。 -- **套餐到期提示**:卡片上只在到期前 7 天内显示,其余时间在详情页查看。 -- **全局重置雷达**:Codex 详情页的预测、事件和服务状态合并为一张卡。先给出未来 24 小时全局重置的可能性(较低 / 有可能 / 较高)和百分比,再展示 48 小时概率、置信度、标出你自己周额度重置时间的时间线、近 30 天重置记录和平均间隔。历史和故障信息可展开查看,数据来源只显示一次。 -- **用量页重置提示**:开启全局重置雷达后,若未来 24 小时全局重置的可能性达到 15% 及以上,或出现官方信号,Codex 卡片会显示「全局重置 N%」或「全局重置信号」标签。该标签不影响账号状态和提醒。 +- **紧凑卡片**:每个账号的标题压缩为一行,每个额度一行:名称、进度条、已用百分比、重置倒计时。进度条上的细竖线表示按平均速度此刻应用到的位置。系统字体放大时自动改为三行显示。 +- **卡片顺序固定**:不再按用量排序,刷新后卡片不会跳动;登录失效和出错的账号排在最前。 +- **重置概率精简**:Codex 详情页只保留未来 24 / 48 小时的全局重置概率,只请求概率这一个接口,不再获取历史事件和服务状态。关闭按钮更明显。 +- **账号详情页更短**:额度改为单行,点击展开完整明细;删除账号单独放在页面最底部。 +- **通知标题更短**:标题只写事件(例如「5 小时窗口已用 80%」),账号名放在正文,避免被截断。 +- **界面清理**:去掉卡片上的标签,新版本提示改为标题下的一行文字;提示图标改为圆形感叹号并与文字对齐;去掉分隔点「·」;设置页去掉加载动画,赞助卡片移到底部,提醒页去掉多余说明;底部导航改用系统图标;中文倒计时的分钟显示为「分」。 + +### 修复 + +- **5 小时重置通知重复**:账号未使用时,5 小时窗口的重置时间会不断后移,导致旧的「重置前提醒」反复通知。该提醒已移除,现在只有额度用满(100%)的窗口才会在重置时通知。 ### 使用说明 -- 全局重置数据来自独立第三方 codex-reset.com,默认关闭,开启前需确认;预测不保证你的个人额度恢复。 +- 重置概率来自独立第三方 codex-reset.com,默认关闭,开启前需确认;预测不保证你的个人额度恢复。 diff --git a/README.md b/README.md index 7dfa37f..6203c77 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ - **中转站一键接入**:New API、Sub2API、One API 等中转站只需地址和 Key,自动识别站点和余额。 - **所有订阅一屏看完**:每个账号的每个额度窗口(5 小时、每周、每月、余额)用横条显示已用与剩余,附重置倒计时;最需要注意的账号排在最前面。 - **用完预测**:按你当前的消耗速度,估算什么时候会用完——如果早于重置就提前告诉你。 -- **Codex 全局辅助信息**:默认关闭。在 ChatGPT / Codex 详情页打开「显示全局重置预测」,同意独立第三方 [codex-reset.com](https://codex-reset.com/) 数据来源后,显示 24 / 48 小时概率、置信度、已确认重置历史及服务状态。开关对所有 Codex 账号生效;取消同意或关闭后不请求该来源。不会向其发送 API Key、ChatGPT Token、账户 ID 或个人用量。它不是 OpenAI 官方个人额度 API;预测不保证个人额度恢复,也不会修改个人窗口重置时间。低置信度和过期缓存会明确标注。 +- **Codex 全局辅助信息**:默认关闭。在 ChatGPT / Codex 详情页的「重置概率」点「开启」,同意独立第三方 [codex-reset.com](https://codex-reset.com/) 数据来源后,只在该详情页显示未来 24 / 48 小时的全局重置概率。开关对所有 Codex 账号生效;取消同意或关闭后不请求该来源。不会向其发送 API Key、ChatGPT Token、账户 ID 或个人用量。它不是 OpenAI 官方个人额度 API;预测不保证个人额度恢复,也不会修改个人窗口重置时间。过期缓存会明确标注。 - **订阅与花费**:显示服务商返回的套餐到期 / 续费日;为每个账号填上月费,首页汇总你每月在 AI 订阅上花了多少。 -- **及时提醒**:用量超过你设的阈值、窗口重置额度恢复、套餐快到期、登录失效时,发本地通知提醒你。 +- **新版本提示**:有新版时在用量页标题下显示一行文字,点一下前往下载;选择不再提示后,同一版本不再出现。 +- **及时提醒**:用量超过你设的阈值、用满(100%)的窗口重置后额度恢复、套餐快到期、登录失效时,发本地通知提醒你。 - **小组件**(iOS):主屏和锁屏小组件,不打开应用也能看余量。 -- **新版本提示**:有新版时在首页标题旁提示,点一下前往下载;关闭后同一版本不再提示。 - **好用的细节**:中文 / English(可跟随系统)、浅色 / 深色、7 种主题色、流畅的动画与触觉反馈,并尊重系统的「减弱动态效果」。 ## 下载与安装 @@ -54,7 +54,7 @@ | 服务 | 连接方式 | 显示内容 | |---|---|---| -| ChatGPT / Codex | 浏览器登录 | 5 小时窗口、每周窗口、额度余额、套餐有效期;独立第三方全局重置预测、事件历史与服务状态 | +| ChatGPT / Codex | 浏览器登录 | 5 小时窗口、每周窗口、额度余额、套餐有效期;独立第三方全局重置概率 | | GitHub Copilot | 个人访问令牌(Token) | Premium 请求、Chat、代码补全用量 | | OpenRouter | 浏览器登录 / API Key | 额度余额、Key 限额、今日 / 本周 / 本月花费 | | Cline / ClinePass | API Key | ClinePass 的 5 小时 / 每周 / 每月用量与套餐到期日;额度余额(含组织余额) | @@ -82,11 +82,11 @@ ## 隐私 - **没有服务器**:应用直接向各服务商请求个人用量。全局重置模块直接读取 Codex Reset 的公开数据,不发送 ChatGPT Token、账户 ID 或个人用量。 +- **版本检查**:应用每隔几小时读取一次本仓库在 GitHub 上的最新 Release,用来提示新版本;请求不携带任何账号或用量信息。 - **凭据只存在本机**:API Key 和登录令牌保存在系统钥匙串(iOS Keychain / Android Keystore)中,不会上传。 - **用量历史只存在本机**:保存在应用的本地数据库里,可在「设置 → 数据」一键全部删除。 - **无广告、无统计**:不收集任何使用数据。 -- **版本检查**:应用每隔几小时读取一次本仓库在 GitHub 上的最新 Release,用来提示新版本;请求不携带任何账号或用量信息。 -- **第三方公共数据**:只有同意来源并开启开关后,应用才在前台及系统允许的后台刷新中读取 Codex Reset;关闭会隐藏模块并中止进行中的公共请求。每个接口至少间隔 5 分钟,缓存保存在本机;网络失败可显示旧数据,第三方不可访问不代表 Codex 故障。展示数据的每个区域均提供可点击的 `Data: codex-reset.com` 署名,遵循其 [接口文档](https://codex-reset.com/developers) 和 [使用条款](https://codex-reset.com/terms)。 +- **第三方公共数据**:只有同意来源并开启开关后,应用才在前台及系统允许的后台刷新中读取 Codex Reset;关闭会隐藏模块并中止进行中的公共请求。只请求概率这一个接口,至少间隔 5 分钟,缓存保存在本机;网络失败可显示旧数据,第三方不可访问不代表 Codex 故障。展示概率的区域提供可点击的 `Data: codex-reset.com` 署名,遵循其 [接口文档](https://codex-reset.com/developers) 和 [使用条款](https://codex-reset.com/terms)。 - 浏览器登录使用本机 `127.0.0.1` 临时回调,只在登录过程中短暂开启,授权完成后立即关闭。 ## 常见问题 @@ -98,7 +98,7 @@ 它按本周期内的平均消耗速度线性估算,适合判断「今天会不会被限流」,不代表精确时间。 **Codex 全局重置概率代表我的额度会恢复吗?** -不代表。它是 Codex Reset 对额外全局事件的预测,不是个人额度倒计时。历史事件只有 `group=reset` 且 `announcement_state=announced` 才列为已确认重置;储备重置、普通公告和未确认信号另行标注。个人额度仍以账户接口及 ChatGPT / Codex 本身为准。本版本不提供全局重置通知。 +不代表。它是 Codex Reset 对额外全局重置的预测,不是个人额度倒计时。个人额度仍以账户接口及 ChatGPT / Codex 本身为准。本版本不提供全局重置通知。 ## 参与开发 diff --git a/assets/images/tabIcons/explore.png b/assets/images/tabIcons/explore.png deleted file mode 100644 index 73d8258..0000000 Binary files a/assets/images/tabIcons/explore.png and /dev/null differ diff --git a/assets/images/tabIcons/explore@2x.png b/assets/images/tabIcons/explore@2x.png deleted file mode 100644 index 21b9bd2..0000000 Binary files a/assets/images/tabIcons/explore@2x.png and /dev/null differ diff --git a/assets/images/tabIcons/explore@3x.png b/assets/images/tabIcons/explore@3x.png deleted file mode 100644 index 422202d..0000000 Binary files a/assets/images/tabIcons/explore@3x.png and /dev/null differ diff --git a/assets/images/tabIcons/home.png b/assets/images/tabIcons/home.png deleted file mode 100644 index ad5699c..0000000 Binary files a/assets/images/tabIcons/home.png and /dev/null differ diff --git a/assets/images/tabIcons/home@2x.png b/assets/images/tabIcons/home@2x.png deleted file mode 100644 index 22a1f2c..0000000 Binary files a/assets/images/tabIcons/home@2x.png and /dev/null differ diff --git a/assets/images/tabIcons/home@3x.png b/assets/images/tabIcons/home@3x.png deleted file mode 100644 index f5d1f9a..0000000 Binary files a/assets/images/tabIcons/home@3x.png and /dev/null differ diff --git a/docs/codex-reset.md b/docs/codex-reset.md index f032510..29e5716 100644 --- a/docs/codex-reset.md +++ b/docs/codex-reset.md @@ -7,31 +7,21 @@ No backend, new native module, dependency or database migration is required. ## Contract verified on 2026-10-08 Read [developers](https://codex-reset.com/developers) and [terms](https://codex-reset.com/terms). -Real GET responses were checked from all three endpoints; reduced, genuine response samples live -under `src/data/codex-reset/__tests__/fixtures/`. They are test inputs, never runtime fallbacks. +Only the reset odds are used: one endpoint, two numbers. A reduced, genuine response sample lives +in `src/data/codex-reset/__tests__/fixtures/forecast.json`; it is a test input, never a runtime +fallback. | Endpoint | Fields used | Interpretation | | --- | --- | --- | -| `/api/forecast` | `probabilities.rounded_24h`, `rounded_48h`, `confidence`, `last_reset_at`, `updated_at`, `official_signal` | Rounded probabilities are percentages from 0 to 100, not fractions. A forecast is not a promise. Unknown/malformed fields display as unknown. | -| `/api/timeline?locale=zh` | `events[].id`, `announced_at`, `group`, `type`, `announcement_state`, `summary`, `localized_summary`, `url`, `updated_at` | The Chinese response retains English `summary` and adds optional `localized_summary`, so both app languages share one copy. Missing translations fall back to original text. | -| `/api/status-history` | `current.codex`, `current.degraded`, `incidents[].id`, `name`, `status`, `started_at`, `resolved_at`, `source_url`, `checked_at`, `stale` | Known outage/maintenance states or explicit degradation indicate a service incident. Unknown states remain unknown. Incident names currently remain in the source language. | - -**Confirmation rule:** only `group === "reset" && announcement_state === "announced"` counts -as a confirmed global reset. Neither `type=reset`, confidence, a probability increase, nor a -banked reset is sufficient. Event timestamps are the source's announcement/confirmation times, -not an invented effective reset time. Forecast `last_reset_at` remains separate from personal -meter timestamps. - -The real forecast had `official_signal: null`. The contract documents no stable inner fields; -a non-null string/object enables a clearly labelled signal link to the source, without inferring -a confirmation or relying on classifier internals. A live non-null signal still needs manual -verification. The captured service status was operational; outage handling is tested with -controlled response variations, not a claim that a live outage was observed. +| `/api/forecast` | `probabilities.rounded_24h`, `rounded_48h`, `updated_at` | Rounded probabilities are percentages from 0 to 100, not fractions. A forecast is not a promise. Missing/malformed values display as unknown. Every other field is dropped at parse time. | + +Timeline (`/api/timeline`) and service status (`/api/status-history`) are not requested. +Confidence, last reset time and official signals are not read or stored. ## Requests, cache and isolation - Public data is **off by default**. The Codex detail screen asks for explicit consent to - `codex-reset.com` before enabling an app-wide switch for forecast, history and service status. + `codex-reset.com` before enabling an app-wide switch for the reset odds. The bilingual dialog identifies this independent service, explains predictions, and states that API keys, ChatGPT tokens, account IDs and personal usage are never sent. Canceling leaves the switch off. Every re-enable asks again; no personal provider configuration is changed. @@ -48,7 +38,7 @@ controlled response variations, not a claim that a live outage was observed. iOS/Android readers; the actual header on devices must be checked on a development build. - The source allows at most one poll per minute and publishes copies with `Cache-Control: max-age=60`. AIUsage is more conservative: successful requests and retries are at least five - minutes apart per endpoint. Language/account changes share the same requests. + minutes apart. Language/account changes share the same requests. - TanStack Query shares foreground data. The app-wide service deduplicates in-flight requests and shares the network gate with manual refresh and headless background refresh. Query retries are disabled; periodic loads retry through the service's persistent exponential backoff (using @@ -56,13 +46,13 @@ controlled response variations, not a claim that a live outage was observed. - HTTP 429 respects both forms of `Retry-After` (seconds and HTTP date), with an origin-wide persistent gate. Manual refresh does not bypass that gate. Concurrent requests already in flight when a 429 arrives can finish; new loads wait. -- Fetch aborts after eight seconds. Each endpoint fails independently and preserves its last - valid response. Attempt gates are reserved before network I/O so interrupted tasks do not +- Fetch aborts after eight seconds. A failure preserves the last valid response. Attempt gates are reserved before network I/O so interrupted tasks do not immediately retry after a restart. Persistence failure still leaves the in-process cache usable. -- Versioned cache envelopes are stored in existing SQLite settings keys (`codex-reset:v1:*`). +- Versioned cache envelopes are stored in existing SQLite settings keys (`codex-reset:v1:*`; + only `forecast` and the origin retry gate are written). Both stored envelopes and external JSON are checked with Zod. No personal snapshot/health rows are written. Old copies are marked when the last request failed, the source/fetch timestamp is - missing or at least 15 minutes old, the published expiry has passed, or upstream reports stale. + missing or at least 15 minutes old, or the published expiry has passed. Expiry can therefore be flagged before the next five-minute poll; old odds are never silently presented as fresh. Successful fetch time never substitutes for an old source update time. - Polling runs only while the details route is focused and the native AppState is active. Global @@ -70,53 +60,37 @@ controlled response variations, not a claim that a live outage was observed. cannot delay personal snapshots or alerts. The background task joins that public work after the personal cycle. Background execution is controlled by iOS/Android and can be interrupted. - 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. + failure is never evidence of an OpenAI outage. Old odds are explicitly labelled as old data. -The radar card displays **Data: codex-reset.com** once, linked to , -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. +The card displays **Data: codex-reset.com** once, linked to , next to +the source update time. 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 -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. +One "Reset chance" section on the Codex details screen, built from `Section`, `Button` and +theme colors, shows only the two odds side by side: "Next 24 hours" and "Next 48 hours", each a +large accent percentage with a word band (`chanceBand`: under 15% low, 15-39% possible, 40% and up +high). Below them sits one footnote: fetch error, stale warning, source time and credit, and a +"Turn off" link. The section footer states that this is an independent prediction and does not +guarantee personal quota recovery. Off, the section is a short explanation and a "Turn on" button +that opens the consent dialog. + +The odds appear only on the Codex details screen; overview cards carry no reset chip. 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. +device timezone for the source time. 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 across accounts and restarts, dedicated notification routing, and source credit in the notification surface. Keeping that work separate avoids interfering with personal reset notification scheduling. -No forecasts or historical resets trigger notifications in this version. +Forecasts trigger no notifications in this version. ## Verification -Unit tests cover real response parsing, malformed/missing fields, unknown enums, confirmation -rules, deduplication, native request headers, both 429 forms, timeout, persistent offline cache, +Unit tests cover real response parsing, malformed/missing fields, dropping unused fields, +probability bands, deduplication to the single endpoint, native request headers, both 429 forms, timeout, persistent offline cache, freshness flags, errors isolated from personal Codex/other provider refresh, bilingual UI, explicit consent, default-off/manual/background gating, persistence, multi-screen revocation, in-flight abort and discarded late results. @@ -126,9 +100,9 @@ Run the project's typecheck, lint, Jest and probe tests. Device checks still req 1. Open a Codex account in iOS/Android development builds. Confirm there are no public requests before agreeing, cancellation leaves the switch off, and disabling stops foreground/background reads after restart. Enable and inspect actual outgoing User-Agent - and confirm no auth or account headers appear on the three public requests. + and confirm no auth or account headers appear on the public request. 2. Verify local event times in a non-UTC zone and change English/Chinese plus theme/accent. -3. Check small-screen wrapping, large accessibility font sizes, source/announcement links, +3. Check small-screen wrapping, large accessibility font sizes, the source link, VoiceOver/TalkBack and reduced motion. 4. Go offline/restart after a successful fetch; verify old public data stays visible with warnings while personal refresh remains independent. Test 429 using a controlled mock, not by hammering diff --git a/src/alerts/__tests__/alerts.test.ts b/src/alerts/__tests__/alerts.test.ts index 0655782..4ecaa1f 100644 --- a/src/alerts/__tests__/alerts.test.ts +++ b/src/alerts/__tests__/alerts.test.ts @@ -68,11 +68,10 @@ describe('mergeAlertSettings', () => { expect( mergeAlertSettings({ thresholds: [0.95, 0.8, 0.8, -1, 0, 9, NaN as never] }).thresholds, ).toEqual([0.8, 0.95]); - expect(mergeAlertSettings({ resetSoon: { hours: 12 } as never }).resetSoon).toEqual({ - enabled: false, - hours: 12, - maxUsedFraction: 0.5, - }); + // a stored setting from the removed "remind me before a reset" rule is dropped + expect(mergeAlertSettings({ resetSoon: { enabled: true } } as never)).not.toHaveProperty( + 'resetSoon', + ); expect(mergeAlertSettings({ enabled: false }).enabled).toBe(false); }); }); @@ -85,8 +84,8 @@ describe('usage-over rule', () => { it('fires for the highest crossed threshold and reports the lower ones for silent marking', () => { const [e] = run([view([pct('weekly', 96, inHours(5))])]); expect(e.kind).toBe('usage-over'); - expect(e.title).toBe('GLM · Work: Weekly at 96%'); - expect(e.body).toBe('Resets in 5h 0m.'); + expect(e.title).toBe('Weekly at 96%'); + expect(e.body).toBe('GLM (Work): resets in 5h 0m.'); expect(e.ruleId).toContain('|usage-over|0.95'); expect(e.alsoMarkFired?.map((x) => x.ruleId)).toEqual([ expect.stringContaining('|usage-over|0.8'), @@ -98,7 +97,7 @@ describe('usage-over rule', () => { view([pct('weekly', 85, inHours(5), 'opus'), pct('weekly', 85, inHours(5), 'sonnet')]), ]); expect(events).toHaveLength(2); - expect(events[0].title).toContain('Weekly · opus'); + expect(events[0].title).toContain('Weekly (opus)'); expect(new Set(events.map((e) => e.ruleId)).size).toBe(2); }); @@ -152,29 +151,16 @@ describe('usage-over rule', () => { }); }); -describe('reset-soon-unused rule', () => { - const on = { resetSoon: { enabled: true, hours: 6, maxUsedFraction: 0.5 } }; - it('fires when a reset is near and most of the quota is unused', () => { - const [e] = run([view([pct('weekly', 20, inHours(4))])], on); - expect(e.kind).toBe('reset-soon-unused'); - expect(e.body).toBe('80% unused, resets in 4h 0m.'); - }); - it('is off by default and does not fire when far from reset, heavily used, or already reset', () => { - expect(run([view([pct('weekly', 20, inHours(4))])])).toEqual([]); - expect(run([view([pct('weekly', 20, inHours(7))])], on)).toEqual([]); - expect(run([view([pct('weekly', 60, inHours(4))])], on)).toEqual([]); - expect(run([view([pct('weekly', 20, inHours(-1))])], on)).toEqual([]); - }); - it('is silenced by a per-meter off override', () => { - const off: AlertRule = { - id: 'o', - accountId: 'a', - meterId: 'weekly', - kind: 'usage-over', - threshold: 1, - enabled: false, - }; - expect(run([view([pct('weekly', 20, inHours(4))])], on, [off])).toEqual([]); +describe('reset notices for windows that are not used up', () => { + it('never fire for an unused or partly used window, however often its reset time moves', () => { + // an idle 5-hour window reports a reset a full window ahead on every refresh + const settings = { windowReset: true, resetSoon: { enabled: true, hours: 6 } } as never; + for (const hours of [5, 4.9, 4.8]) { + for (const used of [0, 20, 99]) { + const events = run([view([pct('session', used, inHours(hours))])], settings); + expect(events.filter((e) => e.kind !== 'usage-over')).toEqual([]); + } + } }); }); @@ -185,7 +171,8 @@ describe('auth-expired rule', () => { it('fires once per expiry (key changes only after a new success)', () => { const [e] = run([expired]); expect(e.kind).toBe('auth-expired'); - expect(e.title).toBe('GLM · Work: sign in again'); + expect(e.title).toBe('Sign in again'); + expect(e.body).toBe('GLM (Work): the login was rejected, usage cannot update.'); const later = view([], { health: { accountId: 'a', @@ -216,7 +203,8 @@ describe('window-reset rule', () => { it('schedules a "quota is back" notice at the reset of a used-up window', () => { const [e] = resets([view([pct('session', 100, inHours(2))])]); expect(e.at).toBe(NOW.getTime() + 2 * 3_600_000); - expect(e.title).toBe('GLM · Work: 5-hour window has reset'); + expect(e.title).toBe('5-hour window has reset'); + expect(e.body).toBe('GLM (Work): the quota is available again.'); expect(e.ruleId).toBe('a|session|window-reset'); }); @@ -250,8 +238,8 @@ describe('renewal-soon rule', () => { it('reminds within the chosen number of days, once per renewal date', () => { const [e] = renewals(withRenewal(2)); - expect(e.title).toContain('GLM · Work: plan ends or renews'); - expect(e.body).toBe('In 2 days. Check your subscription if you do not plan to keep it.'); + expect(e.title).toMatch(/^Plan ends /); + expect(e.body).toBe('GLM (Work): ends or renews in 2 days.'); expect(e.cycleKey).toBe(`a:renew:${Date.parse(inHours(48))}`); }); diff --git a/src/alerts/__tests__/early-reset.test.ts b/src/alerts/__tests__/early-reset.test.ts new file mode 100644 index 0000000..42aa13d --- /dev/null +++ b/src/alerts/__tests__/early-reset.test.ts @@ -0,0 +1,97 @@ +import { detectEarlyResets, type WeeklySeen } from '@/alerts/early-reset'; +import type { Meter } from '@/core/types'; +import type { AccountView } from '@/data/summary'; + +const NOW = new Date('2026-10-06T12:00:00Z'); +const HOUR = 3_600_000; +const at = (h: number) => NOW.getTime() + h * HOUR; + +const view = ( + id: string, + used: number, + resetsInHours: number, + providerId = 'codex', +): AccountView => { + const weekly: Meter = { + id: 'weekly', + label: 'Weekly', + kind: { type: 'percent', used }, + scope: { type: 'overall' }, + resetsAt: new Date(at(resetsInHours)).toISOString(), + }; + return { + account: { id, providerId, label: id, authMethod: 'oauthPkce', sortOrder: 0, createdAt: 1 }, + meta: { id: providerId, name: 'ChatGPT / Codex', automation: 'plugin', auth: ['oauthPkce'] }, + snapshot: { + providerId, + accountId: id, + fetchedAt: NOW.toISOString(), + meters: [weekly], + status: { type: 'ok' }, + }, + health: null, + source: 'auto', + }; +}; + +it('notifies when the weekly window restarts days before its scheduled reset', () => { + const seen: WeeklySeen = { a: { resetsAt: at(48), used: 81 } }; + const { events, next } = detectEarlyResets([view('a', 0, 7 * 24)], seen, NOW, true); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + kind: 'early-reset', + title: 'Codex quota reset early', + body: 'ChatGPT / Codex (a): the quota is available again.', + cycleKey: `a:early:${at(7 * 24)}`, + }); + expect(next.a).toEqual({ resetsAt: at(7 * 24), used: 0 }); +}); + +it('stays quiet for the normal reset, the first sighting, an idle window and small time drift', () => { + // normal reset: the old cycle had already ended + expect( + detectEarlyResets([view('a', 0, 7 * 24)], { a: { resetsAt: at(-0.5), used: 81 } }, NOW, true) + .events, + ).toEqual([]); + // first sighting only records a baseline + expect(detectEarlyResets([view('a', 0, 7 * 24)], {}, NOW, true).events).toEqual([]); + // an unused window has nothing to give back + expect( + detectEarlyResets([view('a', 0, 7 * 24)], { a: { resetsAt: at(48), used: 0 } }, NOW, true) + .events, + ).toEqual([]); + // the reset time wobbling by minutes is not a new window + expect( + detectEarlyResets([view('a', 40, 48.1)], { a: { resetsAt: at(48), used: 41 } }, NOW, true) + .events, + ).toEqual([]); + // other providers are not covered + expect( + detectEarlyResets( + [view('c', 0, 7 * 24, 'cline')], + { c: { resetsAt: at(48), used: 81 } }, + NOW, + true, + ).events, + ).toEqual([]); +}); + +it('sends one notification for several accounts and keeps tracking with the setting off', () => { + const seen: WeeklySeen = { + a: { resetsAt: at(48), used: 81 }, + b: { resetsAt: at(30), used: 20 }, + }; + const views = [view('a', 0, 7 * 24), view('b', 0, 7 * 24)]; + const { events } = detectEarlyResets(views, seen, NOW, true); + expect(events).toHaveLength(1); + expect(events[0].body).toBe( + 'ChatGPT / Codex (a), ChatGPT / Codex (b): the quota is available again.', + ); + expect(events[0].alsoMarkFired).toEqual([ + { ruleId: 'b|weekly|early-reset', cycleKey: `b:early:${at(7 * 24)}` }, + ]); + + const off = detectEarlyResets(views, seen, NOW, false); + expect(off.events).toEqual([]); + expect(Object.keys(off.next)).toEqual(['a', 'b']); +}); diff --git a/src/alerts/early-reset.ts b/src/alerts/early-reset.ts new file mode 100644 index 0000000..3f37a8b --- /dev/null +++ b/src/alerts/early-reset.ts @@ -0,0 +1,75 @@ +import type { AlertEvent } from '@/alerts/evaluate'; +import type { AccountView } from '@/data/summary'; +import { t } from '@/i18n'; + +/** What the last refresh saw of each Codex account's weekly window. */ +export type WeeklySeen = Record; + +export const EARLY_RESET_KEY = 'early-reset:v1'; + +const HOUR = 3_600_000; + +function displayName(v: AccountView): string { + const base = v.meta?.name ?? v.account.providerId; + return v.account.label && v.account.label !== base + ? t('{label} ({detail})', { label: base, detail: v.account.label }) + : base; +} + +/** + * A Codex weekly window that restarted well before its scheduled reset: OpenAI reset the quota + * early (usually a global reset). Detected from the account's own data only, by comparing with + * the previous refresh: + * - the new cycle ends more than an hour later than the old one did (a new window started), and + * - the old cycle still had more than an hour to go (so it is not the normal reset), and + * - usage went down from a window that was actually in use (at least 1%). + * The first sighting of an account only records a baseline. Several accounts resetting together + * give one notification. Returns the events and the state to store for the next refresh. + */ +export function detectEarlyResets( + views: AccountView[], + seen: WeeklySeen, + now: Date, + enabled: boolean, +): { events: AlertEvent[]; next: WeeklySeen } { + const next: WeeklySeen = {}; + const hits: { view: AccountView; ruleId: string; cycleKey: string }[] = []; + for (const v of views) { + if (v.source !== 'auto' || v.account.providerId !== 'codex') continue; + const m = v.snapshot?.meters.find((x) => x.id === 'weekly' && x.scope.type === 'overall'); + const at = m?.resetsAt ? Date.parse(m.resetsAt) : NaN; + if (!m || m.kind.type !== 'percent' || !Number.isFinite(at)) continue; + const id = v.account.id; + const used = m.kind.used; + next[id] = { resetsAt: at, used }; + const prev = seen[id]; + if ( + prev && + at > prev.resetsAt + HOUR && + now.getTime() < prev.resetsAt - HOUR && + prev.used >= 1 && + used < prev.used + ) { + hits.push({ view: v, ruleId: `${id}|weekly|early-reset`, cycleKey: `${id}:early:${at}` }); + } + } + if (!enabled || hits.length === 0) return { events: [], next }; + const [first, ...rest] = hits; + return { + events: [ + { + ruleId: first.ruleId, + cycleKey: first.cycleKey, + kind: 'early-reset', + accountId: first.view.account.id, + title: t('Codex quota reset early'), + body: t('{name}: the quota is available again.', { + name: hits.map((h) => displayName(h.view)).join(', '), + }), + // the other accounts are covered by this one notification + alsoMarkFired: rest.map((h) => ({ ruleId: h.ruleId, cycleKey: h.cycleKey })), + }, + ], + next, + }; +} diff --git a/src/alerts/evaluate.ts b/src/alerts/evaluate.ts index 9e48b74..99b8fca 100644 --- a/src/alerts/evaluate.ts +++ b/src/alerts/evaluate.ts @@ -9,16 +9,11 @@ export interface AlertSettings { enabled: boolean; /** used-fraction thresholds, ascending, e.g. [0.8, 0.95] */ thresholds: number[]; - resetSoon: { - enabled: boolean; - /** notify when a reset is within this many hours... */ - hours: number; - /** ...and at most this fraction has been used */ - maxUsedFraction: number; - }; authExpired: boolean; /** notify the moment a time window that was used up (100%) resets */ windowReset: boolean; + /** notify when a Codex weekly window restarts well before its scheduled reset */ + earlyReset: boolean; /** remind before a paid plan renews or ends */ renewal: { enabled: boolean; days: number }; backgroundRefresh: boolean; @@ -27,9 +22,9 @@ export interface AlertSettings { export const DEFAULT_ALERT_SETTINGS: AlertSettings = { enabled: true, thresholds: [0.8, 0.95], - resetSoon: { enabled: false, hours: 6, maxUsedFraction: 0.5 }, authExpired: true, windowReset: true, + earlyReset: true, renewal: { enabled: true, days: 3 }, backgroundRefresh: true, }; @@ -46,9 +41,9 @@ export function mergeAlertSettings( return { enabled: s.enabled ?? DEFAULT_ALERT_SETTINGS.enabled, thresholds, - resetSoon: { ...DEFAULT_ALERT_SETTINGS.resetSoon, ...s.resetSoon }, authExpired: s.authExpired ?? DEFAULT_ALERT_SETTINGS.authExpired, windowReset: s.windowReset ?? DEFAULT_ALERT_SETTINGS.windowReset, + earlyReset: s.earlyReset ?? DEFAULT_ALERT_SETTINGS.earlyReset, renewal: { ...DEFAULT_ALERT_SETTINGS.renewal, ...s.renewal }, backgroundRefresh: s.backgroundRefresh ?? DEFAULT_ALERT_SETTINGS.backgroundRefresh, }; @@ -106,11 +101,15 @@ const pctText = (f: number) => `${formatNumber(Math.round(f * 100), 0)}%`; function displayName(v: AccountView): string { const base = v.meta?.name ?? v.account.providerId; - return v.account.label && v.account.label !== base ? `${base} · ${v.account.label}` : base; + return v.account.label && v.account.label !== base + ? t('{label} ({detail})', { label: base, detail: v.account.label }) + : base; } function meterName(m: Meter): string { - return m.scope.type === 'model' ? `${t(m.label)} · ${m.scope.name}` : t(m.label); + return m.scope.type === 'model' + ? t('{label} ({detail})', { label: t(m.label), detail: m.scope.name }) + : t(m.label); } /** @@ -137,8 +136,9 @@ export function evaluateAlerts(input: { cycleKey: `${v.account.id}:auth:${v.health?.lastSuccessAt ?? 0}`, kind: 'auth-expired', accountId: v.account.id, - title: t('{name}: sign in again', { name }), - body: t('The saved credential was rejected, so usage can no longer be updated.'), + // short title (it is truncated on the lock screen); the account goes in the body + title: t('Sign in again'), + body: t('{name}: the login was rejected, usage cannot update.', { name }), }); } @@ -152,13 +152,11 @@ export function evaluateAlerts(input: { cycleKey: `${v.account.id}:renew:${renewsAt}`, kind: 'renewal-soon', accountId: v.account.id, - title: t('{name}: plan ends or renews {date}', { name, date: formatDate(renewsAt) }), + title: t('Plan ends {date}', { date: formatDate(renewsAt) }), body: days <= 1 - ? t('Within a day. Check your subscription if you do not plan to keep it.') - : t('In {n} days. Check your subscription if you do not plan to keep it.', { - n: days, - }), + ? t('{name}: ends or renews within a day.', { name }) + : t('{name}: ends or renews in {n} days.', { name, n: days }), }); } } @@ -185,19 +183,20 @@ export function evaluateAlerts(input: { cycleKey: ck, kind: 'usage-over', accountId: v.account.id, - title: t('{name}: {meter} at {pct}', { name, meter: meterName(m), pct: pctText(f) }), + title: t('{meter} at {pct}', { meter: meterName(m), pct: pctText(f) }), body: cd && !cd.elapsed - ? t('Resets in {time}.', { time: formatCountdown(cd.label) }) - : t('Open the app to see details.'), + ? t('{name}: resets in {time}.', { name, time: formatCountdown(cd.label) }) + : name, alsoMarkFired: crossed.slice(0, -1).map((th) => ({ ruleId: idOf(th), cycleKey: ck })), }); } } // --- a used-up time window: count down to its reset, then say the quota is back --- - // Only a window that hit its limit gets one; a window that was barely touched resets - // silently. The dispatcher withdraws a pending notice once no event asks for it any more. + // The only reset notice. It starts once a window reads 100%; an unused or partly used + // window never gets one, even though an idle rolling window keeps reporting a reset time + // a full window ahead. The dispatcher withdraws a pending notice once nothing asks for it. const resetAt = m.resetsAt ? Date.parse(m.resetsAt) : NaN; if ( settings.windowReset && @@ -212,29 +211,11 @@ export function evaluateAlerts(input: { cycleKey: cycleKey(v.account.id, m), kind: 'window-reset', accountId: v.account.id, - title: t('{name}: {meter} has reset', { name, meter: meterName(m) }), - body: t('The quota is available again.'), + title: t('{meter} has reset', { meter: meterName(m) }), + body: t('{name}: the quota is available again.', { name }), at: resetAt, }); } - - // --- big reset coming up with most of the quota unused --- - if (settings.resetSoon.enabled && cd && !cd.elapsed && !(ov && !ov.enabled)) { - const hoursLeft = cd.ms / 3_600_000; - if (hoursLeft <= settings.resetSoon.hours && f <= settings.resetSoon.maxUsedFraction) { - events.push({ - ruleId: `${v.account.id}|${key}|reset-soon-unused`, - cycleKey: cycleKey(v.account.id, m), - kind: 'reset-soon-unused', - accountId: v.account.id, - title: t('{name}: {meter} resets soon', { name, meter: meterName(m) }), - body: t('{pct} unused, resets in {time}.', { - pct: pctText(1 - f), - time: formatCountdown(cd.label), - }), - }); - } - } } } return events; diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 907d1c8..f3779db 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -1,5 +1,6 @@ import { useRouter } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; +import { View } from 'react-native'; import Animated from 'react-native-reanimated'; import { Band } from '@/components/section'; @@ -7,20 +8,23 @@ import { Screen } from '@/components/screen'; import { ScreenHeader } from '@/components/screen-header'; import { ThemedText } from '@/components/themed-text'; import { Spacing } from '@/constants/theme'; -import { formatAmount, formatDate } from '@/core/format'; -import { nextRenewal, sortViews, subscriptionTotals } from '@/data/summary'; +import { formatAgo, formatAmount, formatDate } from '@/core/format'; +import { lastUpdated, nextRenewal, orderViews, subscriptionTotals } from '@/data/summary'; import { useAccountViews, useNow, useRefreshAll, useServices } from '@/data/hooks'; import { useTheme } from '@/hooks/use-theme'; import { useT } from '@/i18n'; import { AccountCard } from '@/ui/account-card'; import { AccountCardSkeleton } from '@/ui/account-card-skeleton'; import { Button } from '@/ui/controls'; +import { HeaderButton } from '@/ui/header-button'; import { RefreshButton } from '@/ui/refresh-button'; -import { UpdatePill } from '@/ui/update-pill'; -import { useUpdateNotice } from '@/data/update-check'; import { summarizeCycle, type RefreshSummary } from '@/data/refresh-summary'; import { haptics } from '@/ui/haptics'; import { RefreshNotice } from '@/ui/refresh-notice'; +import { UpdateNotice } from '@/ui/update-notice'; +import { useUpdateNotice } from '@/data/update-check'; +import { ResetChanceLine } from '@/ui/reset-chance-line'; +import { useCodexResetPreference } from '@/data/codex-reset/preferences'; import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion'; export default function OverviewScreen() { @@ -28,6 +32,7 @@ export default function OverviewScreen() { const theme = useTheme(); const t = useT(); const now = useNow(); + const resetOdds = useCodexResetPreference(); const services = useServices(); const views = useAccountViews(); const refresh = useRefreshAll(); @@ -66,26 +71,41 @@ export default function OverviewScreen() { if (ready) mutate({}); }, [ready, mutate]); - const list = sortViews(views.data ?? []); + // a stable order: problems first, then the user's order (not by usage, so cards don't jump) + const list = orderViews(views.data ?? []); + const updatedAt = lastUpdated(list); const totals = Object.entries(subscriptionTotals(list)); const renewal = nextRenewal(list, now); const loading = views.isLoading && list.length === 0; const empty = list.length === 0 && !views.isLoading; + // Codex reset odds are global: under the only Codex card, or once below the summary when + // several Codex accounts would otherwise repeat them + const codexCount = list.filter((v) => v.account.providerId === 'codex').length; + const sharedOdds = codexCount > 1 && resetOdds.enabled; return ( refreshNow('pull')} refreshing={pulling}> - ) : undefined + subtitle={ + updatedAt === undefined + ? undefined + : t('Updated {ago}', { ago: formatAgo(updatedAt, now.getTime(), t) }) } action={ - refreshNow('button')} /> + + router.push('/add')} + /> + refreshNow('button')} /> + } /> + {update.notice ? : null} + {notice ? : null} {services.error || views.error ? ( @@ -97,7 +117,7 @@ export default function OverviewScreen() { ) : null} {/* the summary appears only with something to say: a priced plan or an upcoming plan end */} - {totals.length > 0 || renewal ? ( + {totals.length > 0 || renewal || sharedOdds ? ( {totals.length > 0 ? ( <> @@ -111,12 +131,18 @@ export default function OverviewScreen() { ) : null} {renewal ? ( - {t('Next plan end: {name} · {date}', { + {t('Next plan end: {name}, {date}', { name: renewal.view.meta?.name ?? renewal.view.account.label, date: formatDate(renewal.at), })} ) : null} + {/* one copy of the global odds for all Codex accounts, part of the summary */} + {sharedOdds ? ( + 0 || renewal ? { marginTop: Spacing.two } : undefined}> + + + ) : null} ) : null} @@ -141,6 +167,11 @@ export default function OverviewScreen() { view={v} now={now} onPress={() => router.push(`/account/${v.account.id}`)} + footer={ + codexCount === 1 && v.account.providerId === 'codex' ? ( + + ) : undefined + } /> ))} diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 5f60fbc..92c0371 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -14,7 +14,7 @@ import { useT } from '@/i18n'; import { usePreferences } from '@/providers/preferences'; import { Button } from '@/ui/controls'; import { haptics } from '@/ui/haptics'; -import { enterFade, enterItem, exitFade, layoutShift, PressableScale } from '@/ui/motion'; +import { exitFade, PressableScale } from '@/ui/motion'; const GITHUB_HANDLE = 'nextroad-dev'; const GITHUB_URL = 'https://github.com/nextroad-dev'; @@ -73,13 +73,48 @@ export default function SettingsScreen() { - {/* sponsor card: the one rounded surface on the page, so it reads as an invitation, not a - setting. It can be closed for good; About then keeps a quiet link instead. */} + +
+ {menu.map((m, i) => ( + + } + title={m.title} + onPress={() => router.push(m.href)} + /> + + ))} +
+
+ + +
+ Linking.openURL(`${GITHUB_URL}`)} + /> + {sponsorDismissed ? ( + + Linking.openURL(SPONSOR_URL)} + /> + + ) : null} +
+
+ + {/* sponsor card: after the settings, so the page opens on what people come for. The one + rounded surface on the page, so it reads as an invitation, not a setting. It can be + closed for good; About then keeps a quiet link instead. */} {sponsorDismissed ? null : ( )} - -
- {menu.map((m, i) => ( - - } - title={m.title} - onPress={() => router.push(m.href)} - /> - - ))} -
-
- - -
- Linking.openURL(`${GITHUB_URL}`)} - /> - {sponsorDismissed ? ( - - Linking.openURL(SPONSOR_URL)} - /> - - ) : null} -
-
- {/* the build, quietly at the very end of the page */} `${m.id}:${m.scope.type === 'model' ? m.scope.name : ''}`; @@ -89,10 +90,11 @@ function SubscriptionSection({ return (
{renewal ? ( - - {renewal.soon ? '▲ ' : ''} - {renewal.text} - + renewal.soon ? ( + {renewal.text} + ) : ( + {renewal.text} + ) ) : null} {/* the currency belongs to the price: offered once there is a price to save it with */} {amount.trim() ? ( - + {t('Price currency')} @@ -129,6 +131,25 @@ function SubscriptionSection({ ); } +/** One line like the overview; a tap shows the full breakdown (used, remaining, exact reset). */ +function ExpandableMeter({ meter, now, view }: { meter: Meter; now: Date; view: AccountView }) { + const t = useT(); + const [open, setOpen] = useState(false); + return ( + { + haptics.tap(); + setOpen(!open); + }} + > + + + ); +} + export default function AccountDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const accountId = String(id); @@ -215,7 +236,7 @@ export default function AccountDetailScreen() { {notice ? : null} - + @@ -224,14 +245,18 @@ export default function AccountDetailScreen() { v.account.label !== providerName ? v.account.label : undefined, v.snapshot?.plan ? formatPlan(v.snapshot.plan) : v.account.manual?.planName, ].filter(Boolean).length > 0 ? ( - + {[ v.account.label !== providerName ? v.account.label : undefined, v.snapshot?.plan ? formatPlan(v.snapshot.plan) : v.account.manual?.planName, ] - .filter(Boolean) - .join(' · ')} - + .filter((x): x is string => !!x) + .map((part) => ( + + {part} + + ))} + ) : null} @@ -269,21 +294,12 @@ export default function AccountDetailScreen() { : undefined } > - {meters.map((m, i) => ( - - - + {meters.map((m) => ( + ))}
)} - {v.account.providerId === 'codex' ? ( - m.id === 'weekly' && m.scope.type === 'overall')} - /> - ) : null} - + {v.account.providerId === 'codex' ? : null} + {v.source === 'auto' && } {renaming ? ( - + setRenaming(false)} /> ) : ( - +
)} + + {/* the destructive action stands apart from everything else, last on the page */} +
+
); } diff --git a/src/app/add/relay.tsx b/src/app/add/relay.tsx index 7198bbd..0a5154d 100644 --- a/src/app/add/relay.tsx +++ b/src/app/add/relay.tsx @@ -25,6 +25,7 @@ import { Button, Field } from '@/ui/controls'; import { unlimitedKeyHint } from '@/ui/relay-hints'; import { haptics } from '@/ui/haptics'; import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion'; +import { Notice } from '@/ui/notice'; /** * Add an API relay from just its address and key: "Detect" recognises the software, the site name @@ -186,7 +187,7 @@ export default function AddRelayScreen() { result.provider.baseUrl.replace(/^https?:\/\//, ''), ] .filter(Boolean) - .join(' · ')} + .join(' ')} @@ -219,19 +220,15 @@ export default function AddRelayScreen() { ) : null} ) : ( - - {'▲ '} + {t('This site does not expose a balance for this key, so it cannot be tracked.')} - + )} {/* an unlimited key has no ceiling to measure against: say how to set one */} {b?.supported && b.unlimited ? ( - - {'▲ '} - {unlimitedKeyHint(result.provider.type, t)} - + {unlimitedKeyHint(result.provider.type, t)} ) : null} @@ -252,7 +249,7 @@ export default function AddRelayScreen() { : result.key.valid ? `● ${t('Key is valid')}` : `○ ${t('Key could not be checked')}`} - {result.capabilities.models ? ` · ${t('OpenAI compatible')}` : ''} + {result.capabilities.models ? ` ${t('OpenAI compatible')}` : ''} diff --git a/src/app/settings/accounts.tsx b/src/app/settings/accounts.tsx index 742d3f6..c78b9fa 100644 --- a/src/app/settings/accounts.tsx +++ b/src/app/settings/accounts.tsx @@ -7,9 +7,8 @@ import { ThemedText } from '@/components/themed-text'; import { useAccountViews } from '@/data/hooks'; import { useT } from '@/i18n'; import { Button } from '@/ui/controls'; -import { enterItem, exitFade } from '@/ui/motion'; +import { exitFade } from '@/ui/motion'; import { ProviderIcon } from '@/ui/provider-icon'; -import { RowSkeleton } from '@/ui/skeleton'; export default function AccountsSettingsScreen() { const router = useRouter(); @@ -20,9 +19,7 @@ export default function AccountsSettingsScreen() { return (
- {accounts.isLoading && !accounts.data ? ( - - ) : views.length === 0 ? ( + {accounts.isLoading && !accounts.data ? null : views.length === 0 ? ( {t('No accounts yet')} @@ -31,7 +28,7 @@ export default function AccountsSettingsScreen() { const name = v.meta?.id === 'relay' ? v.account.label : (v.meta?.name ?? v.account.providerId); return ( - + } title={name} diff --git a/src/app/settings/alerts.tsx b/src/app/settings/alerts.tsx index 9f1e6eb..995a621 100644 --- a/src/app/settings/alerts.tsx +++ b/src/app/settings/alerts.tsx @@ -1,4 +1,4 @@ -import { Linking, Platform, View } from 'react-native'; +import { Alert, Linking, Platform, View } from 'react-native'; import Animated from 'react-native-reanimated'; import { Screen } from '@/components/screen'; @@ -6,10 +6,11 @@ import { Section } from '@/components/section'; import { ThemedText } from '@/components/themed-text'; import { Spacing } from '@/constants/theme'; import { useAlertSettings, useBackgroundState, useNotificationPermission } from '@/data/hooks'; +import { expoNotifier } from '@/alerts/expo-notifier'; import { useT } from '@/i18n'; +import { haptics } from '@/ui/haptics'; import { Button, Segmented } from '@/ui/controls'; -import { enterFade, exitFade, layoutShift } from '@/ui/motion'; -import { RowSkeleton } from '@/ui/skeleton'; +import { exitFade } from '@/ui/motion'; import { MultiChips, ToggleRow } from '@/ui/toggles'; export default function AlertSettingsScreen() { @@ -17,27 +18,30 @@ export default function AlertSettingsScreen() { const { settings, loaded, update } = useAlertSettings(); const permission = useNotificationPermission(); const background = useBackgroundState(settings.backgroundRefresh, loaded); + const sendTest = () => { + haptics.tap(); + expoNotifier + .send({ + ruleId: 'test', + cycleKey: 'test', + kind: 'usage-over', + accountId: '', + title: t('Test notification'), + body: t('Alerts from AI Usage look like this.'), + }) + .catch(() => + Alert.alert(t('Could not send the notification'), t('Check the notification permission.')), + ); + }; - // Until both the stored settings and the permission are known, show placeholders in the page's - // shape: rendering the defaults first would flash the permission banner and toggle sections that - // then jump away. - if (!loaded || permission.state === undefined) { - return ( - -
- -
-
- -
-
- ); - } + // Until both the stored settings and the permission are known (a local read, near instant), + // show nothing: rendering the defaults first would flash the permission banner and toggles. + if (!loaded || permission.state === undefined) return {null}; return ( {permission.state !== 'granted' && ( - +
{permission.state === 'denied' @@ -62,19 +66,14 @@ export default function AlertSettingsScreen() { )} -
+
update({ enabled: v })} /> {settings.enabled && ( - + {t('Notify when usage reaches')} @@ -90,34 +89,24 @@ export default function AlertSettingsScreen() { } /> - update({ resetSoon: { ...settings.resetSoon, enabled: v } })} - /> - {settings.resetSoon.enabled && ( - - ({ value: h, label: t('{n}h', { n: h }) }))} - value={settings.resetSoon.hours} - onChange={(hours) => update({ resetSoon: { ...settings.resetSoon, hours } })} - /> - - )} - + update({ windowReset: v })} /> - + update({ earlyReset: v })} + /> + )}
{settings.enabled && ( - +
update({ renewal: { ...settings.renewal, enabled: v } })} /> {settings.renewal.enabled && ( - + ({ @@ -137,7 +126,7 @@ export default function AlertSettingsScreen() { /> )} - + )} - +
update({ backgroundRefresh: v })} /> {background.data === 'restricted' && ( - + {t('Background refresh is restricted on this device.')} @@ -164,6 +153,14 @@ export default function AlertSettingsScreen() { )}
+ + {permission.state === 'granted' ? ( + +
+
+
+ ) : null} ); } diff --git a/src/components/app-tabs.tsx b/src/components/app-tabs.tsx index 22fe52a..121b8a8 100644 --- a/src/components/app-tabs.tsx +++ b/src/components/app-tabs.tsx @@ -17,17 +17,18 @@ export default function AppTabs() { > {t('Usage')} + {/* system symbols: SF Symbols on iOS, Material icons on Android */} {t('Settings')} diff --git a/src/components/screen-header.tsx b/src/components/screen-header.tsx index 058c27a..91db8a1 100644 --- a/src/components/screen-header.tsx +++ b/src/components/screen-header.tsx @@ -11,20 +11,30 @@ import { Spacing } from '@/constants/theme'; */ export function ScreenHeader({ title, + subtitle, accessory, action, }: { title: string; + /** one secondary line under the title, e.g. when the data was last updated */ + subtitle?: string; accessory?: ReactNode; action?: ReactNode; }) { return ( - - - {title} - - {accessory} + + + + {title} + + {accessory} + + {subtitle ? ( + + {subtitle} + + ) : null} {action} @@ -38,6 +48,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', gap: Spacing.three, }, + text: { flexShrink: 1 }, lead: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, flexShrink: 1 }, title: { flexShrink: 1 }, }); diff --git a/src/components/section.tsx b/src/components/section.tsx index de0a7b3..0bb129a 100644 --- a/src/components/section.tsx +++ b/src/components/section.tsx @@ -4,7 +4,7 @@ import Animated from 'react-native-reanimated'; import { ThemedText } from '@/components/themed-text'; import { Spacing } from '@/constants/theme'; -import { layoutShift, PressableScale } from '@/ui/motion'; +import { PressableScale } from '@/ui/motion'; /** * Full-bleed grouping surface. Replaces cards: no radius, border or shadow — hierarchy comes @@ -27,7 +27,8 @@ export function Band({ /** * A titled group: optional label, one band, optional footnote. Screens compose these instead of - * stacking cards. Sections glide to their new position when content above them grows or shrinks. + * stacking cards. No layout transition: when content above loads in or changes, sections move at + * once instead of sliding, so pages never look like they are still loading. */ export function Section({ title, @@ -41,7 +42,7 @@ export function Section({ children: ReactNode; }) { return ( - + {title ? ( {title} diff --git a/src/core/__tests__/format-setup.test.ts b/src/core/__tests__/format-setup.test.ts index c184af0..3ab0a3f 100644 --- a/src/core/__tests__/format-setup.test.ts +++ b/src/core/__tests__/format-setup.test.ts @@ -54,8 +54,12 @@ describe('formatting', () => { expect(meterText(meter({ type: 'percent', used: 1 })).secondary).toBe('已用'); expect(formatAgo(0, 5 * 60_000)).toBe('5 分钟前'); expect(formatCountdown('2d 3h')).toBe('2天 3小时'); + expect(formatCountdown('3h 12m')).toBe('3小时 12分'); + expect(formatCountdown('45m')).toBe('45分'); + expect(formatCountdown('<1m')).toBe('<1分'); setLocale('en'); expect(formatCountdown('2d 3h')).toBe('2d 3h'); + expect(formatCountdown('3h 12m')).toBe('3h 12m'); }); }); diff --git a/src/core/format.ts b/src/core/format.ts index 59df821..6f3c23b 100644 --- a/src/core/format.ts +++ b/src/core/format.ts @@ -82,5 +82,6 @@ export function formatAgo(thenMs: number, nowMs: number, t: Translator = globalT export function formatCountdown(label: string, t: Translator = globalT): string { return label .replace(/(\d+)d/g, (_m, n) => t('{n}d', { n })) - .replace(/(\d+)h/g, (_m, n) => t('{n}h', { n })); + .replace(/(\d+)h/g, (_m, n) => t('{n}h', { n })) + .replace(/(\d+)m/g, (_m, n) => t('{n}m', { n })); } diff --git a/src/data/__tests__/summary.test.ts b/src/data/__tests__/summary.test.ts index 048e925..8e72171 100644 --- a/src/data/__tests__/summary.test.ts +++ b/src/data/__tests__/summary.test.ts @@ -1,6 +1,8 @@ import { attentionScore, deriveStatus, + lastUpdated, + orderViews, sortViews, subscriptionTotals, type AccountView, @@ -92,6 +94,36 @@ describe('sorting and totals', () => { expect(attentionScore(views[3])).toBe(1); // no ratio => -(-1) }); + it('orders the overview by problems first, then the user order, never by usage', () => { + const views = [ + view('low', { snapshot: snap([pct(10)]) }, 0), + view('high', { snapshot: snap([pct(90)]) }, 1), + view('broken', { snapshot: snap([pct(5)]), health: health({ statusType: 'error' }) }, 2), + view( + 'expired', + { snapshot: snap([pct(5)]), health: health({ statusType: 'authExpired' }) }, + 3, + ), + ]; + expect(orderViews(views).map((v) => v.account.id)).toEqual([ + 'expired', + 'broken', + 'low', + 'high', + ]); + }); + + it('reports the latest successful fetch across automatic accounts', () => { + expect(lastUpdated([])).toBeUndefined(); + expect( + lastUpdated([ + view('a', { snapshot: snap([pct(1)], '2026-10-06T01:00:00.000Z') }), + view('b', { snapshot: snap([pct(1)], '2026-10-06T03:00:00.000Z') }), + view('c', { source: 'legacy', snapshot: snap([pct(1)], '2026-10-07T00:00:00.000Z') }), + ]), + ).toBe(Date.parse('2026-10-06T03:00:00.000Z')); + }); + it('sums manual plan prices per currency', () => { const v = (price: number | undefined, currency?: string) => view('x', { diff --git a/src/data/codex-reset/__tests__/fixtures/status.json b/src/data/codex-reset/__tests__/fixtures/status.json deleted file mode 100644 index 29e2722..0000000 --- a/src/data/codex-reset/__tests__/fixtures/status.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "current": { - "codex": "operational", - "degraded": false - }, - "checked_at": "2026-10-08T15:05:24.710Z", - "stale": false, - "incidents": [ - { - "id": "01M4BP5JE7DKJSP3VG2S2DCWF2", - "name": "Degraded performance in Codex and Work mode: Dot turn failures, delayed responses, and reduced proactivity", - "status": "resolved", - "started_at": "2026-10-07T17:21:52.000Z", - "resolved_at": "2026-10-07T18:23:25.000Z", - "source_url": "https://status.openai.com/incidents/01M4BP5JE7DKJSP3VG2S2DCWF2" - }, - { - "id": "01M4BK9J2M83R4SSC71D9FXW45", - "name": "Dot in Codex may fail to create new threads", - "status": "resolved", - "started_at": "2026-10-07T16:31:37.000Z", - "resolved_at": "2026-10-07T18:06:37.000Z", - "source_url": "https://status.openai.com/incidents/01M4BK9J2M83R4SSC71D9FXW45" - }, - { - "id": "01M4ADHMMBK7K92NJFJ0DXRYJM", - "name": "Unable to create new ChatGPT Work threads in latest version of desktop app", - "status": "resolved", - "started_at": "2026-10-07T05:31:56.000Z", - "resolved_at": "2026-10-07T11:53:35.000Z", - "source_url": "https://status.openai.com/incidents/01M4ADHMMBK7K92NJFJ0DXRYJM" - } - ] -} diff --git a/src/data/codex-reset/__tests__/fixtures/timeline.json b/src/data/codex-reset/__tests__/fixtures/timeline.json deleted file mode 100644 index 2fd290d..0000000 --- a/src/data/codex-reset/__tests__/fixtures/timeline.json +++ /dev/null @@ -1,152 +0,0 @@ -{ - "updated_at": "2026-10-08T15:19:24.742Z", - "events": [ - { - "id": "2107975486798164736", - "announced_at": "2026-10-07T23:24:55.000Z", - "group": "credits", - "type": "credits", - "summary": "The banked reset Tibo announced landed on our monitoring accounts.", - "localized_summary": "Tibo 宣布的存储重置已到达本站监测账号。", - "url": null - }, - { - "id": "2107913674593644711", - "announced_at": "2026-10-07T19:19:17.000Z", - "group": "credits", - "type": "credits", - "summary": "40M active users milestone: one banked reset loaded into every paid Codex and ChatGPT Work account on Day 3 of the 28-day series, alongside GPT-6 in Chat.", - "localized_summary": "40M 活跃用户里程碑:28 天“发布或重置”系列第 3 天,向每个 Codex 与 ChatGPT Work 付费账户发放一个可储备的重置,同日 GPT-6 上线 Chat。", - "url": "https://x.com/thsottiaux/status/2107913674593644711" - }, - { - "id": "2107676653895967106", - "announced_at": "2026-10-07T03:37:27.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "none", - "summary": "Also! Good evening!!", - "localized_summary": "还有!晚上好!!", - "url": "https://x.com/thsottiaux/status/2107676653895967106" - }, - { - "id": "2107676261099426030", - "announced_at": "2026-10-07T03:35:54.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "none", - "summary": "And we won't unship the improvements. You get to have your cake and eat it too. See you tomorrow for Day 3!", - "localized_summary": "我们不会撤回这些改进。您可以鱼与熊掌兼得。明天第三天见!", - "url": "https://x.com/thsottiaux/status/2107676261099426030" - }, - { - "id": "2107676072871600470", - "announced_at": "2026-10-07T03:35:09.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "announced", - "summary": "Full reset for Codex and ChatGPT Work users, picked by community vote in the 28-day ship-or-reset series: “the reset has been processed. Enjoy!”", - "localized_summary": "Codex 与 ChatGPT Work 用户的完整重置:28 天“发布或重置”系列中由社区投票选出——“the reset has been processed. Enjoy!”", - "url": "https://x.com/thsottiaux/status/2107676072871600470" - }, - { - "id": "2106845738814849339", - "announced_at": "2026-10-04T20:35:42.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "none", - "summary": "I challenge you to do the same\n\nhttps://t.co/aUNDJbJ9y2", - "localized_summary": "我挑战你做同样的事 https://t.co/aUNDJbJ9y2", - "url": "https://x.com/thsottiaux/status/2106845738814849339" - }, - { - "id": "2106845241357824205", - "announced_at": "2026-10-04T20:33:43.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "none", - "summary": "Over the next 28 days, each day we’ll either ship one thing that is a clear improvement and relevant for most codex/work users or ship a full reset. Let the improvements begin. htt", - "localized_summary": "在接下来的 28 天里,我们每天要么发布一项对大多数 codex/work 用户来说是明确改进且相关的功能,要么进行一次彻底的重置。让改进开始吧。 https://t.co/0oFXZ7LY5T", - "url": "https://x.com/thsottiaux/status/2106845241357824205" - }, - { - "id": "2106610099720720811", - "announced_at": "2026-10-04T04:59:21.000Z", - "group": "boost", - "type": "boost", - "announcement_state": "none", - "summary": "All right, we’re locking in. Only things being worked on are simplifications, more efficiency for more usage, groundbreaking features or new models. \n\nSometimes you have to invest", - "localized_summary": "好了,我们正在锁定目标。目前仅致力于简化流程、提高效率以支持更多使用、开发突破性功能或推出新模型。 有时你必须走在曲线前面进行投资,但反馈很明确,你们都希望事情变得更简单。正在处理中。", - "url": "https://x.com/thsottiaux/status/2106610099720720811" - }, - { - "id": "2106131810921136451", - "announced_at": "2026-10-02T21:18:48.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "announced", - "summary": "Global reset for all paid ChatGPT accounts, promised the evening before after GPT-6.1 Sol’s slow start and confirmed that afternoon: “Reset all propagated. Enjoy.”", - "localized_summary": "所有付费 ChatGPT 账户的全局重置:GPT-6.1 Sol 开局缓慢后于前一晚承诺,当天下午确认已全部生效——“Reset all propagated. Enjoy.”", - "url": "https://x.com/thsottiaux/status/2106131810921136451" - }, - { - "id": "2105843926221660585", - "announced_at": "2026-10-02T02:14:51.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "none", - "summary": "Global reset landing tomorrow 10am PST for all paid ChatGPT accounts. Apologies for the slow start with GPT-6.1 Sol, it's now back to running at expected speeds after the massive l", - "localized_summary": "全球重置将于明天上午 10am PST 对所有付费 ChatGPT 账户推出。对于 GPT-6.1 Sol 起步缓慢深表歉意,在经历了前两天的巨大负载峰值后,目前已恢复至预期运行速度。", - "url": "https://x.com/thsottiaux/status/2105843926221660585" - }, - { - "id": "2105672058269212820", - "announced_at": "2026-10-01T14:51:54.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "none", - "summary": "Because usage on your primary dot is virtually unlimited at the moment, I can’t really give a reset. I need to come up with something new fast.", - "localized_summary": "由于目前您主点的用量几乎是无限的,我无法真正提供重置。我需要尽快想出一些新办法。", - "url": "https://x.com/thsottiaux/status/2105672058269212820" - }, - { - "id": "2105008012525769969", - "announced_at": "2026-09-29T18:53:14.000Z", - "group": "credits", - "type": "credits", - "summary": "A new banked Codex reset landed on our monitoring accounts. Tibo has not announced it on X yet.", - "localized_summary": "本站监测账号收到了一次新的存储重置,Tibo 尚未在 X 上发推宣布。", - "url": null - }, - { - "id": "2104823812042940713", - "announced_at": "2026-09-29T06:41:17.000Z", - "group": "boost", - "type": "boost", - "announcement_state": "none", - "summary": "Hi,\n\nTomorrow we are re-opening the Pro $200 subscriptions to new subscribers, but together with it we are also changing how we calculate the usage for it. In effect, if you do the", - "localized_summary": "你好, 明天我们将向新订阅者重新开放 Pro $200 订阅,但与此同时,我们也将改变使用量的计算方式。实际上,如果你进行计算,与旧的 Pro $200 方案相比,每美元的 API 支出将减半。 话虽如此,请允许我解释为什么会发生这种情况,以及为什么你仍然能比一个月前使用 Pro $200 订阅时完成更多的工作。 (a) 我们不想在其他方面做出妥协,并承诺不", - "url": "https://x.com/thsottiaux/status/2104823812042940713" - }, - { - "id": "2103963215885701493", - "announced_at": "2026-09-26T21:41:35.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "none", - "summary": "Sorry Gia. More resets coming next week", - "localized_summary": "抱歉,Gia。下周还会有更多重置。", - "url": "https://x.com/thsottiaux/status/2103963215885701493" - }, - { - "id": "2103911959544610829", - "announced_at": "2026-09-26T18:17:54.000Z", - "group": "reset", - "type": "reset", - "announcement_state": "announced", - "summary": "Reset for every paid Codex and ChatGPT Work user, promised after a brief disruption and confirmed fully propagated that evening: “Resets all propagated. That will be all.”", - "localized_summary": "Codex 与 ChatGPT Work 全部付费用户的重置:短暂故障后承诺重置,当晚确认已全部生效——“Resets all propagated. That will be all.”", - "url": "https://x.com/thsottiaux/status/2103911959544610829" - } - ] -} diff --git a/src/data/codex-reset/__tests__/hooks.test.tsx b/src/data/codex-reset/__tests__/hooks.test.tsx index a40ce3f..9ce5b68 100644 --- a/src/data/codex-reset/__tests__/hooks.test.tsx +++ b/src/data/codex-reset/__tests__/hooks.test.tsx @@ -5,12 +5,10 @@ import { AppState, type AppStateStatus } from 'react-native'; import { useServices } from '@/data/hooks'; import { fakeFetch } from '@/test-utils/fetch'; -import { useCodexReset, useRefreshCodexReset } from '../hooks'; +import { useCodexResetForecast, useRefreshCodexReset } from '../hooks'; import { useCodexResetPreference } from '../preferences'; import { CACHE_MS, CONSENT_KEY, CodexResetService, SOURCE } from '../service'; import forecast from './fixtures/forecast.json'; -import timeline from './fixtures/timeline.json'; -import status from './fixtures/status.json'; jest.mock('@/data/hooks', () => ({ useServices: jest.fn() })); jest.mock('expo-router', () => ({ @@ -32,8 +30,6 @@ it('shares real query hooks across components/remount/manual/background loads an ]); const f = fakeFetch({ [`${SOURCE}api/forecast`]: { json: forecast }, - [`${SOURCE}api/timeline?locale=zh`]: { json: timeline }, - [`${SOURCE}api/status-history`]: { json: status }, }); const service = new CodexResetService({ store: { @@ -61,28 +57,28 @@ it('shares real query hooks across components/remount/manual/background loads an ); try { const first = await renderHook( - () => ({ query: useCodexReset(), refresh: useRefreshCodexReset() }), + () => ({ query: useCodexResetForecast(), refresh: useRefreshCodexReset() }), { wrapper }, ); await waitFor(() => - expect(first.result.current.query.status.data?.data?.current.codex).toBe('operational'), + expect(first.result.current.query.data?.data?.probabilities.rounded_24h).toBe(15), ); - expect(f.calls).toHaveLength(3); + expect(f.calls).toHaveLength(1); await act(async () => { first.result.current.refresh(); await service.refresh(); }); - expect(f.calls).toHaveLength(3); + expect(f.calls).toHaveLength(1); await first.unmount(); - const second = await renderHook(() => useCodexReset(), { wrapper }); - expect(second.result.current.forecast.data?.data?.probabilities.rounded_24h).toBe(15); - expect(f.calls).toHaveLength(3); + const second = await renderHook(() => useCodexResetForecast(), { wrapper }); + expect(second.result.current.data?.data?.probabilities.rounded_24h).toBe(15); + expect(f.calls).toHaveLength(1); await act(async () => change('background')); jest.useFakeTimers(); await act(async () => { await jest.advanceTimersByTimeAsync(CACHE_MS * 2); }); - expect(f.calls).toHaveLength(3); + expect(f.calls).toHaveLength(1); await second.unmount(); } finally { client.clear(); @@ -97,8 +93,6 @@ it('keeps mount/manual/background off by default and shares revocation across ac const values = new Map(); const f = fakeFetch({ [`${SOURCE}api/forecast`]: { json: forecast }, - [`${SOURCE}api/timeline?locale=zh`]: { json: timeline }, - [`${SOURCE}api/status-history`]: { json: status }, }); const service = new CodexResetService({ store: { @@ -120,7 +114,7 @@ it('keeps mount/manual/background off by default and shares revocation across ac ); const useScreen = () => ({ preference: useCodexResetPreference(), - query: useCodexReset(), + query: useCodexResetForecast(), refresh: useRefreshCodexReset(), }); try { @@ -135,19 +129,19 @@ it('keeps mount/manual/background off by default and shares revocation across ac await act(async () => { await first.result.current.preference.setEnabled(true); }); - await waitFor(() => expect(second.result.current.query.forecast.data?.data).toBeDefined()); - expect(f.calls).toHaveLength(3); + await waitFor(() => expect(second.result.current.query.data?.data).toBeDefined()); + expect(f.calls).toHaveLength(1); await act(async () => { await second.result.current.preference.setEnabled(false); }); await waitFor(() => expect(first.result.current.preference.enabled).toBe(false)); // Public cache remains local, but disabling every observer prevents reads and new requests. - expect(first.result.current.query.forecast.isFetching).toBe(false); + expect(first.result.current.query.isFetching).toBe(false); await act(async () => { first.result.current.refresh(); await service.refresh(); }); - expect(f.calls).toHaveLength(3); + expect(f.calls).toHaveLength(1); await first.unmount(); await second.unmount(); } finally { diff --git a/src/data/codex-reset/__tests__/service.test.ts b/src/data/codex-reset/__tests__/service.test.ts index e708405..01e52c7 100644 --- a/src/data/codex-reset/__tests__/service.test.ts +++ b/src/data/codex-reset/__tests__/service.test.ts @@ -1,12 +1,6 @@ import { QueryClient } from '@tanstack/react-query'; -import { - forecastSchema, - timelineSchema, - statusSchema, - isConfirmedReset, - codexState, -} from '../models'; +import { forecastSchema } from '../models'; import { CACHE_MS, CONSENT_KEY, @@ -19,13 +13,9 @@ import { } from '../service'; import { fakeFetch } from '@/test-utils/fetch'; import forecast from './fixtures/forecast.json'; -import timeline from './fixtures/timeline.json'; -import status from './fixtures/status.json'; const NOW = Date.parse(forecast.updated_at); const FORECAST = `${SOURCE}api/forecast`; -const TIMELINE = `${SOURCE}api/timeline?locale=zh`; -const STATUS = `${SOURCE}api/status-history`; function setup(routes: Parameters[0]) { const values = new Map([ @@ -52,16 +42,14 @@ function setup(routes: Parameters[0]) { } describe('Codex Reset public contract (real responses captured 2026-10-08)', () => { - it('parses all three real responses and uses percent, not a fractional probability', () => { - expect(forecastSchema.parse(forecast).probabilities).toEqual({ - rounded_24h: 15, - rounded_48h: 28, + it('parses the real response and uses percent, not a fractional probability', () => { + expect(forecastSchema.parse(forecast)).toEqual({ + probabilities: { rounded_24h: 15, rounded_48h: 28 }, + updated_at: forecast.updated_at, }); - expect(timelineSchema.parse(timeline).events).toHaveLength(15); - expect(codexState(statusSchema.parse(status))).toBe('operational'); }); - it('degrades missing/invalid probabilities, timestamps and unknown confidence safely', () => { + it('degrades missing/invalid probabilities and timestamps safely and keeps nothing else', () => { expect( forecastSchema.parse({ probabilities: { rounded_24h: 101, rounded_48h: '28' }, @@ -69,52 +57,14 @@ describe('Codex Reset public contract (real responses captured 2026-10-08)', () last_reset_at: '2026-02-30T00:00:00Z', confidence: 'new-value', }), - ).toMatchObject({ + ).toEqual({ probabilities: { rounded_24h: null, rounded_48h: null }, updated_at: null, - last_reset_at: null, - confidence: 'new-value', }); expect(forecastSchema.parse({ probabilities: {} }).probabilities.rounded_24h).toBeUndefined(); expect(forecastSchema.safeParse({ probabilities: null }).success).toBe(false); expect(forecastSchema.safeParse('').success).toBe(false); }); - - it('requires both documented confirmation fields and ignores malformed events', () => { - const parsed = timelineSchema.parse(timeline); - expect(parsed.events.filter(isConfirmedReset).map((e) => e.id)).toEqual([ - '2107676072871600470', - '2106131810921136451', - '2103911959544610829', - ]); - const event = parsed.events.find(isConfirmedReset)!; - expect(isConfirmedReset({ ...event, announcement_state: 'none' })).toBe(false); - expect(isConfirmedReset({ ...event, announcement_state: null })).toBe(false); - expect(isConfirmedReset({ ...event, announcement_state: 'future-state' })).toBe(false); - expect(isConfirmedReset({ ...event, group: 'credits' })).toBe(false); - expect( - timelineSchema.parse({ - events: [event, { ...event, announced_at: 'bad' }], - }).events, - ).toHaveLength(1); - }); - - it('does not mistake an unknown status enum for an outage or healthy service', () => { - expect(codexState(statusSchema.parse({ ...status, current: { codex: 'new-value' } }))).toBe( - 'unknown', - ); - expect( - codexState(statusSchema.parse({ ...status, current: { codex: 'partial_outage' } })), - ).toBe('incident'); - expect( - codexState( - statusSchema.parse({ - ...status, - current: { codex: 'operational', degraded: true }, - }), - ), - ).toBe('incident'); - }); }); describe('Codex Reset cache and network isolation', () => { @@ -231,11 +181,7 @@ describe('Codex Reset cache and network isolation', () => { }); it('deduplicates simultaneous loads and manual/TanStack/background requests for 5 minutes', async () => { - const s = setup({ - [FORECAST]: { json: forecast }, - [TIMELINE]: { json: timeline }, - [STATUS]: { json: status }, - }); + const s = setup({ [FORECAST]: { json: forecast } }); const [a, b] = await Promise.all([s.service.load('forecast'), s.service.load('forecast')]); expect(a).toBe(b); const client = new QueryClient(); @@ -247,8 +193,8 @@ describe('Codex Reset cache and network isolation', () => { await client.fetchQuery(options); await client.fetchQuery(options); await s.service.refresh(); - expect(s.f.calls.filter((c) => c.url === FORECAST)).toHaveLength(1); - expect(s.f.calls).toHaveLength(3); + // the forecast is the only public endpoint requested + expect(s.f.calls.map((c) => c.url)).toEqual([FORECAST]); s.clock.now += CACHE_MS; await s.service.load('forecast'); expect(s.f.calls.filter((c) => c.url === FORECAST)).toHaveLength(2); @@ -264,7 +210,7 @@ describe('Codex Reset cache and network isolation', () => { }); }); - it('shares Retry-After across endpoints and restarts, even for manual refresh', async () => { + it('keeps Retry-After across restarts, even for manual refresh', async () => { const s = setup({ [FORECAST]: { status: 429, headers: { 'retry-after': '1200' } }, }); @@ -274,7 +220,8 @@ describe('Codex Reset cache and network isolation', () => { s.clock.now += CACHE_MS; await s.service.load('forecast'); const restarted = new CodexResetService(s.deps); - expect((await restarted.load('status')).error).toBe('rate-limited'); + await restarted.refresh(); + expect((await restarted.load('forecast')).error).toBe('rate-limited'); expect(s.f.calls).toHaveLength(1); s.clock.now = first.retryAt; await restarted.load('forecast'); diff --git a/src/data/codex-reset/format.ts b/src/data/codex-reset/format.ts index 23139a1..1fbd1b1 100644 --- a/src/data/codex-reset/format.ts +++ b/src/data/codex-reset/format.ts @@ -1,5 +1,4 @@ import { createTranslator, type Locale, type Translator } from '@/i18n'; -import { isConfirmedReset, type ResetEvent } from './models'; import type { DataError } from './service'; /** Intl defaults to the device's local timezone; never interpret a bare date as an event time. */ @@ -29,22 +28,6 @@ export function localEventTime( }).format(new Date(at)); } -export function confidenceText(value: string | null | undefined, t: Translator): string { - if (value === 'low') return t('Low confidence'); - if (value === 'medium') return t('Medium confidence'); - if (value === 'high') return t('High confidence'); - return t('Confidence unknown'); -} - -export function eventText(event: ResetEvent, t: Translator): string { - if (isConfirmedReset(event)) return t('Confirmed reset'); - if (event.group === 'reset') return t('Unconfirmed reset signal'); - if (event.group === 'boost') return t('Usage boost announcement'); - if (event.group === 'unlock') return t('Unlock announcement'); - if (event.group === 'credits') return t('Credits / banked reset announcement'); - return t('Other announcement'); -} - export function errorText(error: DataError | undefined, t: Translator): string { if (error === 'rate-limited') return t('Codex Reset is rate limited. Retry later.'); if (error === 'timeout') return t('Codex Reset request timed out.'); @@ -68,46 +51,3 @@ export function chanceBandText(band: ChanceBand | undefined, t: Translator): str 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; -} diff --git a/src/data/codex-reset/hooks.ts b/src/data/codex-reset/hooks.ts index a7466ff..60d7387 100644 --- a/src/data/codex-reset/hooks.ts +++ b/src/data/codex-reset/hooks.ts @@ -20,8 +20,11 @@ export function resetQuery(service: CodexResetService, endpoint: Endpoint) { }; } -/** Polls only while the screen is focused and the app is in the foreground, and only after opt-in. */ -function usePollOptions() { +/** + * The reset odds for the details card. Polls only after opt-in, while the screen is focused + * and the app is in the foreground. + */ +export function useCodexResetForecast() { const service = useServices().data?.codexReset; const preference = useCodexResetPreference(); const [focused, setFocused] = useState(false); @@ -36,45 +39,13 @@ function usePollOptions() { const sub = AppState.addEventListener('change', (state) => setActive(state === 'active')); return () => sub.remove(); }, []); - 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, - queryKey: resetKey('forecast'), - queryFn: () => service!.load('forecast'), - }), - timeline: useQuery({ - ...options, - queryKey: resetKey('timeline'), - queryFn: () => service!.load('timeline'), - }), - status: useQuery({ - ...options, - queryKey: resetKey('status'), - queryFn: () => service!.load('status'), - }), - }; -} - -/** 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, + enabled: !!service && preference.enabled && focused && active, + staleTime: CACHE_MS, + gcTime: 24 * 60 * 60_000, + retry: false as const, + refetchInterval: CACHE_MS, + refetchIntervalInBackground: false, queryKey: resetKey('forecast'), queryFn: () => service!.load('forecast'), }); @@ -87,10 +58,6 @@ export function useRefreshCodexReset() { const client = useQueryClient(); return () => { if (!service || !preference.enabled) return; - void Promise.allSettled( - (['forecast', 'timeline', 'status'] as const).map((endpoint) => - client.fetchQuery(resetQuery(service, endpoint)), - ), - ); + void client.fetchQuery(resetQuery(service, 'forecast')).catch(() => {}); }; } diff --git a/src/data/codex-reset/models.ts b/src/data/codex-reset/models.ts index 5875868..0ebe8c0 100644 --- a/src/data/codex-reset/models.ts +++ b/src/data/codex-reset/models.ts @@ -1,110 +1,23 @@ import { z } from 'zod'; -/** Only the public contract is used; classifier internals are intentionally ignored. */ +/** Only the reset odds and their source time are read; every other field is ignored. */ const timestamp = z.iso.datetime({ offset: true }); const optionalTime = timestamp.nullish().catch(null); -const optionalText = z.string().nullish().catch(null); const probability = z.number().finite().min(0).max(100).nullish().catch(null); -const httpsUrl = z - .url() - .refine((v) => v.startsWith('https://')) - .nullish() - .catch(null); export const forecastSchema = z.object({ probabilities: z.object({ rounded_24h: probability, rounded_48h: probability, }), - confidence: optionalText, - last_reset_at: optionalTime, updated_at: optionalTime, - // No stable inner signal fields are documented. Offer the source rather than invent copy. - official_signal: z - .union([z.string(), z.record(z.string(), z.unknown())]) - .nullish() - .catch(null), -}); - -export const eventSchema = z.object({ - id: z.string().min(1), - announced_at: timestamp, - group: z.string(), - type: optionalText, - announcement_state: optionalText, - summary: z.string(), - localized_summary: optionalText, - url: httpsUrl, -}); - -// A malformed event should not discard the remaining history. -const validEvents = z.array(z.unknown()).transform((items) => - items.flatMap((item) => { - const parsed = eventSchema.safeParse(item); - return parsed.success ? [parsed.data] : []; - }), -); -export const timelineSchema = z.object({ - updated_at: optionalTime, - events: validEvents, -}); - -export const incidentSchema = z.object({ - id: z.string().min(1), - name: z.string(), - status: z.string(), - started_at: timestamp, - resolved_at: optionalTime, - source_url: httpsUrl, -}); -export const statusSchema = z.object({ - current: z.object({ - codex: optionalText, - degraded: z.boolean().nullish().catch(null), - }), - checked_at: optionalTime, - stale: z.boolean().optional().catch(true), - incidents: z.array(z.unknown()).transform((items) => - items.flatMap((item) => { - const parsed = incidentSchema.safeParse(item); - return parsed.success ? [parsed.data] : []; - }), - ), }); export const schemas = { forecast: forecastSchema, - timeline: timelineSchema, - status: statusSchema, }; export type Endpoint = keyof typeof schemas; export type Forecast = z.infer; -export type Timeline = z.infer; -export type ServiceStatus = z.infer; -export type ResetEvent = z.infer; -export type Incident = z.infer; export type Payloads = { forecast: Forecast; - timeline: Timeline; - status: ServiceStatus; }; - -/** This is the documented confirmation rule, not the event's type or confidence. */ -export const isConfirmedReset = (event: ResetEvent): boolean => - event.group === 'reset' && event.announcement_state === 'announced'; - -export function newestFirst(items: T[], time: (item: T) => string): T[] { - return [...items].sort((a, b) => Date.parse(time(b)) - Date.parse(time(a))); -} - -export function codexState(status: ServiceStatus): 'operational' | 'incident' | 'unknown' { - const state = status.current.codex; - if ( - status.current.degraded === true || - ['degraded_performance', 'partial_outage', 'major_outage', 'under_maintenance'].includes( - state ?? '', - ) - ) - return 'incident'; - return state === 'operational' ? 'operational' : 'unknown'; -} diff --git a/src/data/codex-reset/service.ts b/src/data/codex-reset/service.ts index 1497c14..86046e3 100644 --- a/src/data/codex-reset/service.ts +++ b/src/data/codex-reset/service.ts @@ -21,8 +21,6 @@ const consentSchema = z.object({ }); const paths: Record = { forecast: 'forecast', - timeline: 'timeline', - status: 'status-history', }; export type DataError = 'rate-limited' | 'timeout' | 'invalid' | 'unavailable'; @@ -142,7 +140,6 @@ export class CodexResetService { } load(endpoint: K): Promise> { - // The zh timeline includes original English summaries too: one bilingual shared copy. const key = endpoint; const inflight = this.running.get(key); if (inflight) return inflight as Promise>; @@ -199,7 +196,7 @@ export class CodexResetService { let expires: string | undefined; try { const result = await requestJson( - `${SOURCE}api/${paths[endpoint]}${endpoint === 'timeline' ? '?locale=zh' : ''}`, + `${SOURCE}api/${paths[endpoint]}`, { timeoutMs: TIMEOUT_MS, headers: { 'User-Agent': USER_AGENT }, @@ -275,9 +272,7 @@ export class CodexResetService { } async refresh(): Promise { - await Promise.all( - (['forecast', 'timeline', 'status'] as const).map((endpoint) => this.load(endpoint)), - ); + await this.load('forecast'); } } diff --git a/src/data/services.ts b/src/data/services.ts index dcde430..4cff67a 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -9,6 +9,7 @@ import { syncWidget } from '@/widgets/sync'; import type { CycleDeps } from '@/refresh/coordinator'; import type { RefreshDeps } from '@/refresh/refresh-account'; import { CodexResetService } from '@/data/codex-reset/service'; +import { MOCK_DATA, mockFetch, syncMockAccounts } from '@/dev/mock-data'; export interface Services { repos: Repos; @@ -28,15 +29,20 @@ let cached: Promise | undefined; export function getServices(): Promise { cached ??= (async () => { const repos = await getRepos(); + const real: typeof fetch = (...a) => fetch(...a); + // development builds can answer demo accounts from canned data (see src/dev/mock-data.ts) + const net = MOCK_DATA ? mockFetch(real) : real; + const accounts = new AccountService(repos, credentialStore); + if (__DEV__) await syncMockAccounts(accounts, repos); const refreshDeps: RefreshDeps = { repos, creds: credentialManager, - fetch: (...a) => fetch(...a), + fetch: net, now: () => new Date(), }; const codexReset = new CodexResetService({ store: repos.settings, - fetch: (...a) => fetch(...a), + fetch: net, now: () => Date.now(), }); const refreshPublicData = async () => { @@ -48,7 +54,7 @@ export function getServices(): Promise { repos, credentials: credentialStore, manager: credentialManager, - accounts: new AccountService(repos, credentialStore), + accounts, refreshDeps, cycleDeps: { ...refreshDeps, diff --git a/src/data/summary.ts b/src/data/summary.ts index b4d070e..c4760a4 100644 --- a/src/data/summary.ts +++ b/src/data/summary.ts @@ -66,6 +66,35 @@ export function sortViews(views: AccountView[]): AccountView[] { ); } +/** + * Overview order: accounts that need the user (login expired, then errors) first, otherwise the + * user's own order. Unlike `sortViews` it does not depend on usage, so cards stay put when a + * refresh changes the numbers. + */ +export function orderViews(views: AccountView[]): AccountView[] { + const rank = (v: AccountView) => { + const s = deriveStatus(v).kind; + return s === 'authExpired' ? 0 : s === 'error' || s === 'unsupported' ? 1 : 2; + }; + return [...views].sort( + (a, b) => + rank(a) - rank(b) || + a.account.sortOrder - b.account.sortOrder || + a.account.createdAt - b.account.createdAt, + ); +} + +/** When any automatic account last fetched successfully (epoch ms). */ +export function lastUpdated(views: AccountView[]): number | undefined { + let best: number | undefined; + for (const v of views) { + if (v.source !== 'auto') continue; + const at = deriveStatus(v).lastSuccessAt; + if (at !== undefined && Number.isFinite(at) && (best === undefined || at > best)) best = at; + } + return best; +} + /** Sum of known monthly plan prices per currency: entered prices first, legacy manual configs otherwise. */ export function subscriptionTotals(views: AccountView[]): Record { const out: Record = {}; diff --git a/src/data/update-check.ts b/src/data/update-check.ts index e1adac2..8f06fe8 100644 --- a/src/data/update-check.ts +++ b/src/data/update-check.ts @@ -8,7 +8,7 @@ import { useServices } from '@/data/hooks'; * Checked at most every few hours; the user can dismiss a version, and that version is never * offered again (a newer one is). */ -export const RELEASES_API = 'https://api.github.com/repos/nextroad-dev/aiusage/releases/latest'; +export const RELEASES_API = 'https://api.github.com/repos/nextroad-dev/AIUsage/releases/latest'; const DISMISSED_KEY = 'update-dismissed-version'; const CHECK_EVERY_MS = 6 * 3_600_000; diff --git a/src/db/repos.ts b/src/db/repos.ts index 7f8784b..4a5dcd7 100644 --- a/src/db/repos.ts +++ b/src/db/repos.ts @@ -341,7 +341,7 @@ export class ManualStateRepo { // ---------------- alerts ---------------- export type AlertKind = - 'usage-over' | 'reset-soon-unused' | 'auth-expired' | 'window-reset' | 'renewal-soon'; + 'usage-over' | 'auth-expired' | 'window-reset' | 'renewal-soon' | 'early-reset'; export interface AlertRule { id: string; @@ -349,7 +349,7 @@ export interface AlertRule { meterId: string; model?: string; kind: AlertKind; - /** usage-over: fraction 0..1; reset-soon-unused: hours before reset */ + /** usage-over: fraction 0..1 */ threshold: number; enabled: boolean; } diff --git a/src/dev/__tests__/mock-data.test.ts b/src/dev/__tests__/mock-data.test.ts new file mode 100644 index 0000000..6f18725 --- /dev/null +++ b/src/dev/__tests__/mock-data.test.ts @@ -0,0 +1,75 @@ +import { usedFraction } from '@/core/meter-utils'; +import { forecastSchema } from '@/data/codex-reset/models'; +import { pluginFor } from '@/providers/service'; +import { DEMOS, mockFetch } from '../mock-data'; + +jest.useFakeTimers(); + +const real = jest.fn(async () => new Response('real', { status: 200 })) as unknown as typeof fetch; +const net = mockFetch(real); + +async function snapshot(id: string) { + const demo = DEMOS.find((d) => d.account.id === id)!; + const plugin = pluginFor(demo.account.providerId)!; + const work = plugin.fetchUsage(id, demo.cred, { fetch: net, now: () => new Date() }); + await jest.runAllTimersAsync(); + return work; +} + +it('feeds every demo account through the real provider parsing', async () => { + const busy = await snapshot('mock-codex-busy'); + expect(busy.meters.map((m) => [m.id, usedFraction(m)])).toEqual([ + ['session', 0.72], + ['weekly', 0.81], + ['credits', undefined], + ]); + expect(busy.meters[0].resetsAt).toBeDefined(); + + const full = await snapshot('mock-codex-full'); + expect(usedFraction(full.meters[0])).toBe(1); + + const idle = await snapshot('mock-codex-idle'); + expect(usedFraction(idle.meters[0])).toBe(0); + + const copilot = await snapshot('mock-copilot'); + expect(copilot.meters).toHaveLength(1); + expect(usedFraction(copilot.meters[0])).toBeCloseTo(0.7); + + const openrouter = await snapshot('mock-openrouter'); + expect(openrouter.meters.map((m) => m.id)).toEqual([ + 'credits', + 'key_limit', + 'usage_daily', + 'usage_weekly', + 'usage_monthly', + ]); + + const kimi = await snapshot('mock-kimi'); + expect(kimi.meters.map((m) => [m.id, usedFraction(m)])).toEqual([ + ['monthly', 0.6], + ['session', 0.9], + ]); + + // rejected token: the refresh pipeline turns this into the sign-in-again state + const demo = DEMOS.find((d) => d.account.id === 'mock-copilot-expired')!; + const expired = pluginFor('copilot')! + .fetchUsage(demo.account.id, demo.cred, { fetch: net, now: () => new Date() }) + .then( + () => 'ok', + (e: Error) => e.name, + ); + await jest.runAllTimersAsync(); + expect(await expired).toBe('AuthExpiredError'); +}); + +it('answers Codex Reset with odds the app accepts and leaves real accounts on the network', async () => { + const res = net('https://codex-reset.com/api/forecast'); + await jest.runAllTimersAsync(); + const forecast = forecastSchema.parse(await (await res).json()); + expect(forecast.probabilities).toEqual({ rounded_24h: 22, rounded_48h: 41 }); + + await net('https://chatgpt.com/backend-api/wham/usage', { + headers: { Authorization: 'Bearer real-token' }, + }); + expect(real).toHaveBeenCalledTimes(1); +}); diff --git a/src/dev/mock-data.ts b/src/dev/mock-data.ts new file mode 100644 index 0000000..9f9301e --- /dev/null +++ b/src/dev/mock-data.ts @@ -0,0 +1,290 @@ +import type { AccountService } from '@/authkit/account-service'; +import type { Credential } from '@/core/types'; +import type { Account, Repos } from '@/db/repos'; + +/** + * Demo data for trying the app on a real device without real subscriptions. + * + * Development builds only: set `EXPO_PUBLIC_MOCK_DATA=1` in `.env.local` and reload. A few + * demo accounts are added (ids start with `mock-`), and network requests that carry a demo + * credential get canned responses, so the real provider parsing, refresh, forecasts and alerts + * all run. Requests from real accounts still reach the real services. Codex Reset odds are canned + * too, once you opt in on a Codex details screen. Turning the flag off removes the demo accounts + * on the next start. Release builds never include any of this (`__DEV__` is false). + */ +export const MOCK_DATA = __DEV__ && process.env.EXPO_PUBLIC_MOCK_DATA === '1'; + +const PREFIX = 'mock-'; +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +/** Reset times stay put across refreshes (like a real window) by counting from app start. */ +const START = Date.now(); +const cycle = (now: number, period: number, firstAfter: number) => { + let at = START + firstAfter; + while (at <= now) at += period; + return at; +}; +const secs = (ms: number) => Math.floor(ms / 1000); + +interface Demo { + account: Omit; + cred: Credential; +} + +const oauth = (token: string): Credential => ({ + type: 'oauth', + accessToken: token, + // far future, so no token refresh is attempted + expiresAt: Date.now() + 365 * DAY, + accountId: token, +}); +const key = (token: string): Credential => ({ type: 'apiKey', key: token }); + +export const DEMOS: Demo[] = [ + { + // busy: 5-hour window ahead of an even pace, weekly heading to run out before its reset + account: { + id: 'mock-codex-busy', + providerId: 'codex', + label: 'Demo busy', + authMethod: 'oauthPkce', + createdAt: 1, + }, + cred: oauth('mock-codex-busy'), + }, + { + // idle: an unstarted 5-hour window keeps reporting a reset a full window ahead + account: { + id: 'mock-codex-idle', + providerId: 'codex', + label: 'Demo idle', + authMethod: 'oauthPkce', + createdAt: 2, + }, + cred: oauth('mock-codex-idle'), + }, + { + // used up: the 5-hour window reads 100% and resets 3 minutes after launch (reset notice); + // its weekly window then restarts early at 4 minutes (early-reset notice) + account: { + id: 'mock-codex-full', + providerId: 'codex', + label: 'Demo used up', + authMethod: 'oauthPkce', + createdAt: 3, + }, + cred: oauth('mock-codex-full'), + }, + { + account: { + id: 'mock-copilot', + providerId: 'copilot', + label: 'Demo', + authMethod: 'apiKey', + createdAt: 4, + }, + cred: key('mock-copilot'), + }, + { + // the service rejects this token: shows the sign-in-again state + account: { + id: 'mock-copilot-expired', + providerId: 'copilot', + label: 'Demo expired', + authMethod: 'apiKey', + createdAt: 5, + }, + cred: key('mock-copilot-expired'), + }, + { + account: { + id: 'mock-openrouter', + providerId: 'openrouter', + label: 'Demo', + authMethod: 'apiKey', + createdAt: 6, + }, + cred: key('mock-openrouter'), + }, + { + account: { + id: 'mock-kimi', + providerId: 'kimi-code', + label: 'Demo', + authMethod: 'apiKey', + createdAt: 7, + }, + cred: key('mock-kimi'), + }, +]; + +/** The used-up demo's weekly window restarts early 4 minutes after launch (early-reset notice). */ +const EARLY_RESET_AT = 4 * MIN; + +function codexUsage(token: string, now: number): unknown { + const weekly = (used: number, firstAfter: number) => ({ + used_percent: used, + limit_window_seconds: secs(7 * DAY), + reset_at: secs(cycle(now, 7 * DAY, firstAfter)), + }); + const fullWeekly = () => + now < START + EARLY_RESET_AT + ? weekly(64, 4 * DAY) + : { + used_percent: 0, + limit_window_seconds: secs(7 * DAY), + reset_at: secs(START + EARLY_RESET_AT + 7 * DAY), + }; + if (token === 'mock-codex-busy') { + return { + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 72, + limit_window_seconds: secs(5 * HOUR), + reset_at: secs(cycle(now, 5 * HOUR, 2 * HOUR + 13 * MIN)), + }, + secondary_window: weekly(81, 2 * DAY + 5 * HOUR), + }, + credits: { balance: 12.4 }, + }; + } + if (token === 'mock-codex-full' && now < START + 3 * MIN) { + return { + plan_type: 'pro', + rate_limit: { + primary_window: { + used_percent: 100, + limit_window_seconds: secs(5 * HOUR), + reset_at: secs(START + 3 * MIN), + }, + secondary_window: fullWeekly(), + }, + }; + } + // idle (and the used-up account after its reset): the window has not started + return { + plan_type: token === 'mock-codex-full' ? 'pro' : 'plus', + rate_limit: { + primary_window: { + used_percent: 0, + limit_window_seconds: secs(5 * HOUR), + reset_after_seconds: secs(5 * HOUR), + reset_at: secs(now + 5 * HOUR), + }, + secondary_window: token === 'mock-codex-full' ? fullWeekly() : weekly(3, 6 * DAY + 20 * HOUR), + }, + credits: { balance: 0 }, + }; +} + +function copilotUser(now: number): unknown { + const d = new Date(now); + return { + copilot_plan: 'individual_pro', + quota_reset_date_utc: new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), + ).toISOString(), + quota_snapshots: { + premium_interactions: { entitlement: 300, remaining: 90, unlimited: false }, + chat: { unlimited: true }, + completions: { unlimited: true }, + }, + }; +} + +function kimiUsages(now: number): unknown { + return { + usage: { + limit: 2000, + used: 1200, + resetTime: new Date(cycle(now, 30 * DAY, 11 * DAY)).toISOString(), + }, + limits: [ + { + detail: { + limit: 100, + used: 90, + resetTime: new Date(cycle(now, 5 * HOUR, 47 * MIN)).toISOString(), + }, + }, + ], + }; +} + +function route( + url: string, + token: string | undefined, + now: number, +): { status: number; json?: unknown } | undefined { + if (url === 'https://codex-reset.com/api/forecast') { + return { + status: 200, + json: { + probabilities: { rounded_24h: 22, rounded_48h: 41 }, + updated_at: new Date(now - 2 * MIN).toISOString(), + }, + }; + } + if (!token?.startsWith(PREFIX)) return undefined; + if (token === 'mock-copilot-expired') + return { status: 401, json: { message: 'Bad credentials' } }; + if (url === 'https://chatgpt.com/backend-api/wham/usage') + return { status: 200, json: codexUsage(token, now) }; + if (url === 'https://api.github.com/copilot_internal/user') + return { status: 200, json: copilotUser(now) }; + if (url === 'https://openrouter.ai/api/v1/key') { + return { + status: 200, + json: { + data: { limit: 20, usage: 13.5, usage_daily: 0.82, usage_weekly: 4.1, usage_monthly: 13.5 }, + }, + }; + } + if (url === 'https://openrouter.ai/api/v1/credits') { + return { status: 200, json: { data: { total_credits: 25, total_usage: 16.8 } } }; + } + if (url.endsWith('/coding/v1/usages')) return { status: 200, json: kimiUsages(now) }; + return { status: 404 }; +} + +function bearer(init?: RequestInit): string | undefined { + const h = new Headers(init?.headers); + const auth = h.get('authorization'); + return auth?.replace(/^(Bearer|token)\s+/i, ''); +} + +/** Wraps the real fetch: demo credentials and Codex Reset get canned answers after a short delay. */ +export function mockFetch(real: typeof fetch): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const answer = route(url, bearer(init), Date.now()); + if (!answer) return real(input, init); + // a little latency so loading states and pull to refresh are visible + await new Promise((resolve) => setTimeout(resolve, 400 + Math.random() * 600)); + return new Response(answer.json === undefined ? '' : JSON.stringify(answer.json), { + status: answer.status, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; +} + +/** Adds the demo accounts that are missing; removes them all when demo mode is off. Never throws. */ +export async function syncMockAccounts(accounts: AccountService, repos: Repos): Promise { + try { + const existing = new Set((await repos.accounts.list()).map((a) => a.id)); + if (!MOCK_DATA) { + if (!__DEV__) return; + for (const id of existing) + if (id.startsWith(PREFIX)) await accounts.remove(id).catch(() => {}); + return; + } + for (const d of DEMOS) { + if (!existing.has(d.account.id)) await accounts.add(d.account, d.cred).catch(() => {}); + } + } catch { + // demo data is a convenience; the app works without it + } +} diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 703133e..38828de 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -2,79 +2,55 @@ // are translated at render time (registry notes, meter labels, service messages). // A test enforces coverage. export const zh: Record = { + // notifications: short titles, the account in the body + 'Sign in again': '需要重新登录', + '{name}: the login was rejected, usage cannot update.': '{name}:登录已失效,用量无法更新。', + 'Plan ends {date}': '套餐 {date} 到期', + '{name}: ends or renews within a day.': '{name}:1 天内到期或续费。', + '{name}: ends or renews in {n} days.': '{name}:{n} 天后到期或续费。', + '{meter} at {pct}': '{meter}已用 {pct}', + '{name}: resets in {time}.': '{name}:{time}后重置。', + '{meter} has reset': '{meter}已重置', + 'Codex quota reset early': 'Codex 额度已提前重置', + 'Show details': '展开详情', + Quota: '额度', + 'Send a test notification': '发送测试通知', + 'Test notification': '测试通知', + 'Alerts from AI Usage look like this.': 'AI Usage 的提醒会这样显示。', + 'Could not send the notification': '通知发送失败', + 'Check the notification permission.': '请检查通知权限。', + 'Show less': '收起', + 'Tell me when Codex resets early': 'Codex 提前重置时通知我', + '{name}: the quota is available again.': '{name}:额度已恢复。', // Codex Reset public data - 'Global reset radar': '全局重置雷达', + 'Reset chance': '重置概率', + 'Next 24 hours': '未来 24 小时', + 'Next 48 hours': '未来 48 小时', + 'Turn off reset chance': '关闭重置概率', + 'Codex reset chance': 'Codex 重置概率', + '24H reset chance': '24H重置概率', + '48H reset chance': '48H重置概率', + 'Reset odds 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?': + '重置概率直接从 codex-reset.com 获取,它是独立第三方服务,并非 OpenAI。你的 API Key、ChatGPT 登录令牌、账户 ID 和个人用量都不会发送给该网站。预测不保证你的个人额度恢复。是否同意在开关开启期间请求这些公共数据,包括系统允许的后台刷新?', "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?': - '预测、全局事件和服务状态直接从 codex-reset.com 获取,它是独立第三方服务,并非 OpenAI。你的 API Key、ChatGPT 登录令牌、账户 ID 和个人用量都不会发送给该网站。预测不保证你的个人额度恢复。是否同意在开关开启期间请求这些公共数据,包括系统允许的后台刷新?', 'Agree and enable': '同意并开启', 'Could not save the public data setting': '公共数据设置未能保存', 'Try changing the switch again.': '请重新操作开关。', - 'View status report': '查看故障报告', 'Today {time}': '今天 {time}', 'Yesterday {time}': '昨天 {time}', - Probability: '概率', Unknown: '未知', - 'Low confidence': '低置信度', - 'Medium confidence': '中等置信度', - 'High confidence': '高置信度', - 'Confidence unknown': '置信度未知', - 'Low-confidence forecast: treat these odds with caution.': '预测置信度较低,请谨慎参考这些概率。', - 'Last confirmed global reset': '上次已确认的全局重置', - 'Official signal reported': '来源报告了官方信号', - '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 官方额度接口。全局重置不保证你的个人额度恢复。', - 'Unconfirmed signals and other announcements are not confirmed global resets.': - '未确认信号和其他公告不代表已确认的全局重置。', - 'Confirmed reset': '已确认重置', - 'Unconfirmed reset signal': '未确认重置信号', - 'Usage boost announcement': '额度提升公告', - 'Unlock announcement': '功能开放公告', - 'Credits / banked reset announcement': '积分 / 储备重置公告', - 'Other announcement': '其他公告', - 'No confirmed resets reported.': '暂无已确认的重置记录。', - 'View announcement': '查看公告', - 'Show less': '收起', - 'Codex operational': 'Codex 运行正常', - 'Codex service incident reported': '来源报告了 Codex 服务故障', - 'Codex status unknown': 'Codex 状态未知', - 'A platform incident is different from exhausted personal quota.': - '平台服务故障与个人额度耗尽是不同的问题。', - 'Ongoing incident': '故障处理中', - 'Status unknown': '状态未知', - Resolved: '已恢复', 'No public data available.': '暂无可用的公共数据。', 'Source updated {time}': '来源更新时间:{time}', 'Outdated data — showing the last available copy.': '数据已过期,当前显示最后可用的缓存。', @@ -93,6 +69,7 @@ export const zh: Record = { '{n}d ago': '{n} 天前', '{n}d': '{n}天', '{n}h': '{n}小时', + '{n}m': '{n}分', 'Updated {ago}': '{ago}更新', 'Resets in {time}': '{time}后重置', 'Reset due': '已到重置时间', @@ -128,8 +105,7 @@ export const zh: Record = { 'Open platforms': '开放平台', Relays: '中转站', Other: '其他', - 'New v{version}': '新版本 v{version}', - 'New version {version} available': '有新版本 {version}', + 'New version {version} available': '有新版本 v{version},点击下载', 'Dismiss this version': '不再提示此版本', 'Tip the author': '打赏作者', Afdian: '爱发电', @@ -203,7 +179,7 @@ export const zh: Record = { 'Login expired': '登录已失效', Unsupported: '不支持', Error: '出错', - 'Error · data from {ago}': '出错 · 数据来自 {ago}', + 'Error, data from {ago}': '出错,数据来自 {ago}', // add flow 'Manual (automatic support coming)': '手动(自动采集待支持)', @@ -318,21 +294,12 @@ export const zh: Record = { Alerts: '提醒', 'Usage alerts': '用量提醒', 'Notify when usage reaches': '用量达到以下比例时通知', - 'Remind me before a reset': '重置前提醒', 'Allow notifications': '允许通知', 'Background refresh': '后台刷新', 'iOS decides when this runs, so alerts can arrive late. Opening the app always checks too.': '何时运行由 iOS 决定,提醒可能延迟。打开 App 时也会检查。', Default: '默认', Off: '关闭', - '{name}: sign in again': '{name}:请重新登录', - 'The saved credential was rejected, so usage can no longer be updated.': - '保存的凭据被拒绝,用量无法继续更新。', - '{name}: {meter} at {pct}': '{name}:{meter} 已达 {pct}', - 'Resets in {time}.': '{time}后重置。', - 'Open the app to see details.': '打开 App 查看详情。', - '{name}: {meter} resets soon': '{name}:{meter} 即将重置', - '{pct} unused, resets in {time}.': '还有 {pct} 未用,{time}后重置。', // OAuth/API-key only and historical compatibility OAuth: 'OAuth', @@ -442,17 +409,9 @@ export const zh: Record = { 'Shows your Cline credits and, with ClinePass, the 5-hour, weekly and monthly limits. Create an API key at app.cline.bot under Settings > API Keys.': '显示 Cline 额度余额;订阅了 ClinePass 时还会显示 5 小时、每周和每月用量。请在 app.cline.bot 的 Settings > API Keys 中创建 API Key。', // subscriptions, forecasts, reset notices, widget - '{name}: plan ends or renews {date}': '{name}:套餐将于 {date} 到期或续费', - 'Within a day. Check your subscription if you do not plan to keep it.': - '不到 1 天。如不打算继续,请检查订阅。', - 'In {n} days. Check your subscription if you do not plan to keep it.': - '还有 {n} 天。如不打算继续,请检查订阅。', - '{name}: {meter} has reset': '{name}:{meter}已重置', - 'The quota is available again.': '额度已恢复,可以继续使用。', 'Subscriptions per month': '每月订阅合计', - 'Next plan end: {name} · {date}': '最近到期:{name} · {date}', + 'Next plan end: {name}, {date}': '最近到期:{name},{date}', 'Tell me when a window resets': '窗口重置时通知我', - 'Only for windows that were used up.': '仅限额度已用完的窗口。', 'Remind me before a plan ends': '套餐到期前提醒我', '{n} days before': '提前 {n} 天', Subscription: '订阅', @@ -461,7 +420,8 @@ export const zh: Record = { 'Runs out in {time} at this pace': '按当前速度约 {time}后用完', 'within a day': '不到 1 天', '{n} days left': '剩 {n} 天', - 'Plan until {date} · {left}': '套餐有效至 {date} · {left}', + 'Plan until {date} ({left})': '套餐有效至 {date}({left})', + '{label} ({detail})': '{label}({detail})', 'Open AI Usage to add an account.': '打开 AI Usage 添加账号。', Resets: '重置', '5h': '5小时', diff --git a/src/refresh/__tests__/coordinator.test.ts b/src/refresh/__tests__/coordinator.test.ts index efeed30..332e65a 100644 --- a/src/refresh/__tests__/coordinator.test.ts +++ b/src/refresh/__tests__/coordinator.test.ts @@ -68,8 +68,6 @@ describe('runRefreshCycle', () => { }, [POE]: { json: { current_point_balance: 100 } }, [`${SOURCE}api/forecast`]: { status: 401 }, - [`${SOURCE}api/timeline?locale=zh`]: { status: 500 }, - [`${SOURCE}api/status-history`]: { status: 503 }, }); // Match the production Expo driver's serialized transactions; the basic memory fake // rejects overlapping transactions from two otherwise successful provider refreshes. diff --git a/src/refresh/coordinator.ts b/src/refresh/coordinator.ts index 8ddef82..5a3658e 100644 --- a/src/refresh/coordinator.ts +++ b/src/refresh/coordinator.ts @@ -1,4 +1,5 @@ import { dispatchAlerts, type Notifier } from '@/alerts/dispatch'; +import { detectEarlyResets, EARLY_RESET_KEY, type WeeklySeen } from '@/alerts/early-reset'; import { evaluateAlerts, mergeAlertSettings } from '@/alerts/evaluate'; import { loadViews } from '@/data/load'; import type { AccountView } from '@/data/summary'; @@ -45,7 +46,9 @@ async function maintenance(deps: CycleDeps): Promise { async function doCycle(deps: CycleDeps, force: boolean): Promise> { const { repos, now } = deps; const outcomes = await refreshAll(deps, { force }); - void Promise.resolve().then(() => deps.refreshPublicData?.()).catch(() => {}); + void Promise.resolve() + .then(() => deps.refreshPublicData?.()) + .catch(() => {}); const views = await loadViews(repos, now()); const settings = mergeAlertSettings(await repos.settings.getJson('alerts', null)); const events = evaluateAlerts({ @@ -54,6 +57,16 @@ async function doCycle(deps: CycleDeps, force: boolean): Promise(EARLY_RESET_KEY, {}).catch(() => ({})); + const early = detectEarlyResets( + views, + seen ?? {}, + now(), + settings.enabled && settings.earlyReset, + ); + events.push(...early.events); + await repos.settings.setJson(EARLY_RESET_KEY, early.next).catch(() => {}); const { sent } = await dispatchAlerts(events, { alerts: repos.alerts, notifier: deps.notifier, diff --git a/src/ui/__tests__/codex-reset-section.test.tsx b/src/ui/__tests__/codex-reset-section.test.tsx index fd707bd..8fa217f 100644 --- a/src/ui/__tests__/codex-reset-section.test.tsx +++ b/src/ui/__tests__/codex-reset-section.test.tsx @@ -1,203 +1,85 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { Alert, Linking } from 'react-native'; -import type { Meter } from '@/core/types'; -import { forecastSchema, timelineSchema, statusSchema } from '@/data/codex-reset/models'; -import { useCodexReset, useCodexResetForecast } from '@/data/codex-reset/hooks'; +import { forecastSchema } from '@/data/codex-reset/models'; +import { useCodexResetForecast } from '@/data/codex-reset/hooks'; import { useCodexResetPreference } from '@/data/codex-reset/preferences'; -import { - averageIntervalDays, - chanceBand, - confidenceText, - eventText, - localEventTime, - resetDays, -} from '@/data/codex-reset/format'; +import { chanceBand, localEventTime } from '@/data/codex-reset/format'; import { createTranslator, setLocale } from '@/i18n'; -import { CodexResetChip } from '@/ui/codex-reset-chip'; import { CodexResetSection } from '@/ui/codex-reset-section'; +import { ResetChanceLine } from '@/ui/reset-chance-line'; import forecast from '@/data/codex-reset/__tests__/fixtures/forecast.json'; -import timeline from '@/data/codex-reset/__tests__/fixtures/timeline.json'; -import status from '@/data/codex-reset/__tests__/fixtures/status.json'; -jest.mock('@/data/codex-reset/hooks', () => ({ - useCodexReset: jest.fn(), - useCodexResetForecast: jest.fn(), -})); +jest.mock('@/data/codex-reset/hooks', () => ({ useCodexResetForecast: jest.fn() })); jest.mock('@/data/codex-reset/preferences', () => ({ useCodexResetPreference: jest.fn() })); const usePreference = useCodexResetPreference as jest.Mock; -const useReset = useCodexReset as jest.Mock; const useForecast = useCodexResetForecast as jest.Mock; const NOW = new Date(forecast.updated_at); -const entry = (data: unknown) => ({ - data: { data, fetchedAt: NOW.getTime(), retryAt: 0, failures: 0 }, +const query = (data: unknown, extra: object = {}) => ({ + data: { + data: forecastSchema.parse(data), + fetchedAt: NOW.getTime(), + retryAt: 0, + failures: 0, + ...extra, + }, isLoading: false, }); -const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); beforeEach(() => { setLocale('en'); - useReset.mockClear(); + useForecast.mockReset(); usePreference.mockReturnValue({ enabled: true, loaded: true, pending: false, setEnabled: jest.fn().mockResolvedValue(undefined), }); - useReset.mockReturnValue({ - forecast: entry(forecastSchema.parse(forecast)), - timeline: entry(timelineSchema.parse(timeline)), - status: entry(statusSchema.parse(status)), - }); + useForecast.mockReturnValue(query(forecast)); }); afterEach(() => { setLocale('en'); jest.restoreAllMocks(); }); -const weekly = (hoursLeft: number): Meter => ({ - id: 'weekly', - label: 'Weekly', - kind: { type: 'percent', used: 81 }, - scope: { type: 'overall' }, - resetsAt: new Date(NOW.getTime() + hoursLeft * 3_600_000).toISOString(), -}); - -it('answers first with the 24-hour chance and its band, then the evidence, with one source credit', async () => { +it('shows only the 24- and 48-hour reset chances, each with its band, and one source credit', async () => { const open = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); - await render(); - expect(screen.getByText('Chance of a global reset in the next 24 hours')).toBeTruthy(); - expect(screen.getByText('15%')).toBeTruthy(); - expect(screen.getByText('Possible')).toBeTruthy(); - expect(screen.getByText('48 hours: 28%')).toBeTruthy(); - expect(screen.getByText('Low-confidence forecast: treat these odds with caution.')).toBeTruthy(); - expect( - screen.getByText('Your weekly quota is 81% used and resets on its own in 1d 6h.'), - ).toBeTruthy(); - // the strip is decorative for screen readers; the sentence above carries the same facts - expect(screen.getByText('▲ Your weekly reset', { includeHiddenElements: true })).toBeTruthy(); - expect(screen.getByLabelText(/3 days with a confirmed reset/)).toBeTruthy(); - expect(screen.getByText('about 5 days')).toBeTruthy(); + await render(); + expect(screen.getByText('Reset chance')).toBeTruthy(); + expect(screen.getByLabelText('Next 24 hours: 15%, Possible')).toBeTruthy(); + expect(screen.getByLabelText('Next 48 hours: 28%, Possible')).toBeTruthy(); expect(screen.getByText(/A global reset does not guarantee your personal quota/)).toBeTruthy(); - expect(screen.getByText('Codex operational')).toBeTruthy(); - // history and incidents wait behind one button - expect(screen.queryByText('Confirmed reset')).toBeNull(); - expect(screen.queryByText(status.incidents[0].name)).toBeNull(); - await fireEvent.press(screen.getByText('History and service status')); - expect(screen.getAllByText('Confirmed reset')).toHaveLength(3); - expect(screen.getByText(status.incidents[0].name)).toBeTruthy(); - expect(screen.getAllByRole('link', { name: 'Data: codex-reset.com' })).toHaveLength(1); + expect(screen.queryByText(/confidence/i)).toBeNull(); await fireEvent.press(screen.getByRole('link', { name: 'Data: codex-reset.com' })); expect(open).toHaveBeenCalledWith('https://codex-reset.com/'); }); -it('pins a weekly reset beyond 48 hours to the end of the strip', async () => { - await render(); - expect( - screen.getByText('Your weekly reset is later →', { includeHiddenElements: true }), - ).toBeTruthy(); - expect( - screen.getByText('Your weekly quota is 81% used and resets on its own in 2d 5h.'), - ).toBeTruthy(); -}); - -it('shows Chinese copy and translated announcements and keeps local timestamps', async () => { +it('shows Chinese copy and the local source time', async () => { setLocale('zh'); await render(); - expect(screen.getByText('全局重置雷达')).toBeTruthy(); + expect(screen.getByText('重置概率')).toBeTruthy(); + expect(screen.getByText('未来 24 小时')).toBeTruthy(); expect(screen.getByText('15%')).toBeTruthy(); - expect(screen.getByText('有可能')).toBeTruthy(); + expect(screen.getAllByText('有可能')).toHaveLength(2); expect( - screen.getByText(new RegExp(escape(localEventTime(forecast.last_reset_at, 'zh', NOW)))), + screen.getByText( + createTranslator('zh')('Source updated {time}', { + time: localEventTime(forecast.updated_at, 'zh', NOW), + }), + ), ).toBeTruthy(); - expect(screen.queryByText(timeline.events[0].localized_summary!)).toBeNull(); - await fireEvent.press(screen.getByText('历史与服务状态')); - expect(screen.getAllByText('已确认重置')).toHaveLength(3); - expect(screen.getByText(timeline.events[0].localized_summary!)).toBeTruthy(); - expect(screen.getByText('Codex 运行正常')).toBeTruthy(); }); -it('degrades missing probabilities and signals, warns on old cached data without claiming an outage', async () => { - const q = useReset(); - q.forecast.data.data = forecastSchema.parse({ - probabilities: {}, - confidence: 'future-confidence', - }); - q.forecast.data.error = 'unavailable'; - q.status = { - isLoading: false, - data: { retryAt: 0, failures: 1, error: 'unavailable' }, - }; +it('degrades missing probabilities and warns on old cached data without claiming an outage', async () => { + useForecast.mockReturnValue(query({ probabilities: {} }, { error: 'unavailable' })); await render(); expect(screen.getAllByText('Unknown')).toHaveLength(2); - expect(screen.getByText('48 hours: Unknown')).toBeTruthy(); - expect(screen.getByText('Confidence unknown')).toBeTruthy(); - expect( - screen.getAllByText('Outdated data — showing the last available copy.').length, - ).toBeGreaterThan(0); - // one footnote for the card, not one per failed endpoint - expect( - screen.getAllByText('Codex Reset data unavailable. This does not indicate a Codex outage.'), - ).toHaveLength(1); - expect(screen.getByText('Outdated data')).toBeTruthy(); - expect(screen.getByText('Codex status unknown')).toBeTruthy(); - expect(screen.queryByText('Codex service incident reported')).toBeNull(); - expect(screen.queryByText('Codex operational')).toBeNull(); -}); - -it('reports an explicit platform incident separately from quota and data failures', async () => { - const q = useReset(); - q.status = entry( - statusSchema.parse({ - ...status, - current: { codex: 'major_outage', degraded: true }, - }), - ); - await render(); - expect(screen.getByText('Codex service incident reported')).toBeTruthy(); + expect(screen.getByText('Outdated data — showing the last available copy.')).toBeTruthy(); expect( - screen.getByText('A platform incident is different from exhausted personal quota.'), + screen.getByText('Codex Reset data unavailable. This does not indicate a Codex outage.'), ).toBeTruthy(); }); -it('does not present a type=reset event as confirmed and renders an official signal conservatively', async () => { - const q = useReset(); - q.timeline.data.data = timelineSchema.parse({ - ...timeline, - events: [timeline.events[2]], - }); - q.forecast.data.data.official_signal = { future_field: 'unknown' }; - await render(); - await fireEvent.press(screen.getByText('History and service status')); - expect(screen.getByText('Unconfirmed reset signal')).toBeTruthy(); - expect(screen.queryByText('Confirmed reset')).toBeNull(); - expect(screen.getByText('A signal is not a confirmed reset.')).toBeTruthy(); -}); - -it('formats invalid time safely and relies on the device timezone in both locales', () => { - const value = '2026-10-07T03:35:09.000Z'; - for (const [locale, tag] of [ - ['en', 'en-US'], - ['zh', 'zh-CN'], - ] as const) { - expect(localEventTime(value, locale)).toBe( - new Intl.DateTimeFormat(tag, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(value)), - ); - expect(localEventTime('bad', locale)).toBe('—'); - expect(confidenceText('future', createTranslator(locale))).toBe( - createTranslator(locale)('Confidence unknown'), - ); - } - const e = timelineSchema.parse(timeline).events[2]; - expect(eventText(e, createTranslator('zh'))).toBe('未确认重置信号'); -}); - it.each(['en', 'zh'] as const)( 'requires explicit source consent before enabling in %s', async (locale) => { @@ -211,7 +93,7 @@ it.each(['en', 'zh'] as const)( }); const alert = jest.spyOn(Alert, 'alert'); await render(); - expect(useReset).not.toHaveBeenCalled(); + expect(useForecast).not.toHaveBeenCalled(); expect(screen.queryByText('15%')).toBeNull(); await fireEvent.press(screen.getByText(locale === 'zh' ? '开启' : 'Turn on')); const [, message, buttons] = alert.mock.calls[0]; @@ -231,7 +113,7 @@ it('disables immediately without another consent dialog and reports persistence usePreference.mockReturnValue({ enabled: true, loaded: true, pending: false, setEnabled: save }); const alert = jest.spyOn(Alert, 'alert'); await render(); - await fireEvent.press(screen.getByText('Turn off global reset radar')); + await fireEvent.press(screen.getByText('Turn off reset chance')); expect(save).toHaveBeenCalledWith(false); await waitFor(() => expect(alert).toHaveBeenCalledWith( @@ -242,6 +124,25 @@ it('disables immediately without another consent dialog and reports persistence expect(alert).toHaveBeenCalledTimes(1); }); +it('formats invalid time safely and relies on the device timezone in both locales', () => { + const value = '2026-10-07T03:35:09.000Z'; + for (const [locale, tag] of [ + ['en', 'en-US'], + ['zh', 'zh-CN'], + ] as const) { + expect(localEventTime(value, locale)).toBe( + new Intl.DateTimeFormat(tag, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)), + ); + expect(localEventTime('bad', locale)).toBe('—'); + } +}); + it('formats today and yesterday using local calendar dates across a year boundary', () => { const now = new Date(2027, 0, 1, 0, 15); const yesterday = new Date(2026, 11, 31, 23, 50).toISOString(); @@ -264,7 +165,7 @@ it('formats today and yesterday using local calendar dates across a year boundar } }); -it('bands probabilities, marks 30 local days and averages confirmed intervals', () => { +it('bands probabilities into words', () => { expect([null, 0, 14, 15, 39, 40, 100].map(chanceBand)).toEqual([ undefined, 'low', @@ -274,48 +175,23 @@ it('bands probabilities, marks 30 local days and averages confirmed intervals', 'high', 'high', ]); - const events = timelineSchema.parse(timeline).events; - const days = resetDays(events, NOW); - expect(days).toHaveLength(30); - expect(days.filter((d) => d === 'confirmed')).toHaveLength(3); - // non-reset groups (credits, boost) never mark a day - expect(resetDays(events.filter((e) => e.group !== 'reset'), NOW).every((d) => !d)).toBe(true); - expect(averageIntervalDays(events.filter((e) => e.announcement_state === 'announced'))).toBe(5); - expect(averageIntervalDays(events.slice(4, 5))).toBeUndefined(); }); -describe('CodexResetChip', () => { - const chip = (data: unknown) => - useForecast.mockReturnValue({ - data: { data: forecastSchema.parse(data), fetchedAt: NOW.getTime(), retryAt: 0, failures: 0 }, - }); - - it('shows a possible-or-better 24-hour chance on the overview', async () => { - chip(forecast); - await render(); - expect(screen.getByText('Global reset 15%')).toBeTruthy(); +describe('ResetChanceLine (usage page)', () => { + it('shows both odds on one plain line after opt-in', async () => { + await render(); + expect(screen.getByLabelText('24H reset chance, 15%, Possible')).toBeTruthy(); + expect(screen.getByLabelText('48H reset chance, 28%, Possible')).toBeTruthy(); + expect(screen.queryByText('Outdated data')).toBeNull(); }); - it('stays quiet for a low chance, for old data and before opt-in', async () => { - chip({ ...forecast, probabilities: { rounded_24h: 9, rounded_48h: 20 } }); - await render(); - expect(screen.queryByText(/Global reset/)).toBeNull(); - - chip(forecast); - await render(); - expect(screen.queryByText(/Global reset/)).toBeNull(); - + it('renders nothing before opt-in or without cached odds', async () => { + useForecast.mockReturnValue({ data: undefined, isLoading: true }); + await render(); + expect(screen.queryByLabelText(/24H reset chance/)).toBeNull(); usePreference.mockReturnValue({ enabled: false, loaded: true, pending: false }); useForecast.mockClear(); - await render(); - expect(screen.queryByText(/Global reset/)).toBeNull(); + await render(); expect(useForecast).not.toHaveBeenCalled(); }); - - it('labels an official signal without calling it confirmed', async () => { - chip({ ...forecast, probabilities: { rounded_24h: 5 }, official_signal: 'x' }); - await render(); - expect(screen.getByText('Global reset signal')).toBeTruthy(); - expect(screen.getByLabelText('Global reset signal reported, not confirmed')).toBeTruthy(); - }); }); diff --git a/src/ui/__tests__/components.test.tsx b/src/ui/__tests__/components.test.tsx index 2b555cb..d536193 100644 --- a/src/ui/__tests__/components.test.tsx +++ b/src/ui/__tests__/components.test.tsx @@ -62,7 +62,8 @@ describe('AccountCard', () => { }); await render( {}} />); expect(screen.getByText('GLM Coding Plan (z.ai)')).toBeTruthy(); - expect(screen.getByText('Work · Pro')).toBeTruthy(); + expect(screen.getByText('Work')).toBeTruthy(); + expect(screen.getByText('Pro')).toBeTruthy(); expect(screen.getByText(/5-hour window/)).toBeTruthy(); expect(screen.getByText(/Weekly/)).toBeTruthy(); // one line per window: used share and a short countdown; remaining is left to the details @@ -156,7 +157,7 @@ describe('AccountCard', () => { onPress={onPress} />, ); - expect(screen.getByText(/Weekly · opus/)).toBeTruthy(); + expect(screen.getByText('Weekly (opus)')).toBeTruthy(); await fireEvent.press(screen.getByRole('button')); expect(onPress).toHaveBeenCalled(); }); @@ -174,7 +175,7 @@ describe('AccountCard', () => { onPress={() => {}} />, ); - expect(screen.getByText('3小时 12m后重置'.replace('12m', '12m'))).toBeTruthy(); + expect(screen.getByText('3小时 12分后重置')).toBeTruthy(); expect(screen.getByText(/只读/)).toBeTruthy(); } finally { setLocale('en'); @@ -312,6 +313,7 @@ describe('AccountCard plan', () => { onPress={() => {}} />, ); - expect(screen.getByText('Work · Plus')).toBeTruthy(); + expect(screen.getByText('Work')).toBeTruthy(); + expect(screen.getByText('Plus')).toBeTruthy(); }); }); diff --git a/src/ui/account-card.tsx b/src/ui/account-card.tsx index 70287c1..d8dd720 100644 --- a/src/ui/account-card.tsx +++ b/src/ui/account-card.tsx @@ -1,17 +1,15 @@ +import type { ReactNode } from 'react'; import { StyleSheet, View } from 'react-native'; import { ThemedText } from '@/components/themed-text'; -import { Radius, Spacing } from '@/constants/theme'; +import { Spacing } from '@/constants/theme'; import { formatPlan } from '@/core/format'; import { visibleMeters } from '@/core/meter-utils'; import { deriveStatus, type AccountView } from '@/data/summary'; -import { useTheme } from '@/hooks/use-theme'; import { useT } from '@/i18n'; -import { renewalInfo } from '@/ui/meter-text'; import { MeterRow } from '@/ui/meter-row'; import { PressableScale } from '@/ui/motion'; import { ProviderIcon } from '@/ui/provider-icon'; -import { CodexResetChip } from '@/ui/codex-reset-chip'; import { StatusBadge } from '@/ui/status-badge'; export { forecastText, meterTitle, renewalInfo, resetText } from '@/ui/meter-text'; @@ -22,26 +20,26 @@ export function AccountCard({ view, now, onPress, + footer, }: { view: AccountView; now: Date; onPress: () => void; + /** extra line under the meters, e.g. the Codex reset odds */ + footer?: ReactNode; }) { const t = useT(); - const theme = useTheme(); const status = deriveStatus(view); - const renewal = renewalInfo(view, now, t); const meters = visibleMeters(view.snapshot?.meters ?? []); // a relay is known by its own site name, not the generic "API relay" const name = view.meta?.id === 'relay' ? view.account.label : (view.meta?.name ?? view.account.providerId); + // separate texts, spaced by the header's gap rather than joined with a separator const subtitle = [ view.account.label !== name ? view.account.label : undefined, // a reported plan is tidied up; a name the user typed is shown as typed view.snapshot?.plan ? formatPlan(view.snapshot.plan) : view.account.manual?.planName, - ] - .filter(Boolean) - .join(' · '); + ].filter((x): x is string => !!x); return ( - + {name} - {subtitle ? ( + {subtitle.map((part) => ( - {subtitle} + {part} - ) : null} + ))} + {/* a problem shows as plain text; the plan end lives on the details screen */} - {view.account.providerId === 'codex' ? : null} - {/* the plan end shows here only when it is close; the details screen always has it */} - {renewal?.soon ? ( - - - {'▲ '} - {renewal.left} - - - ) : null} @@ -105,6 +95,7 @@ export function AccountCard({ : t('No usage data.')} )} + {footer} ); @@ -120,6 +111,7 @@ const styles = StyleSheet.create({ rowGap: Spacing.one, }, shrink: { flexShrink: 1 }, + name: { fontWeight: 700 }, trailing: { marginLeft: 'auto', flexDirection: 'row', @@ -128,11 +120,5 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', gap: Spacing.one, }, - chip: { - borderWidth: 1, - borderRadius: Radius.pill, - paddingHorizontal: Spacing.two, - paddingVertical: 1, - }, meters: { gap: Spacing.one }, }); diff --git a/src/ui/codex-reset-chip.tsx b/src/ui/codex-reset-chip.tsx deleted file mode 100644 index 551d737..0000000 --- a/src/ui/codex-reset-chip.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { StyleSheet, View } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { Radius, Spacing } from '@/constants/theme'; -import { chanceBand } from '@/data/codex-reset/format'; -import { useCodexResetForecast } from '@/data/codex-reset/hooks'; -import { useCodexResetPreference } from '@/data/codex-reset/preferences'; -import { isDataStale } from '@/data/codex-reset/service'; -import { useTheme } from '@/hooks/use-theme'; -import { useT } from '@/i18n'; - -/** - * Overview hint that a global reset looks possible. Only after opt-in, only with fresh data and - * only when worth a glance (a "possible" band or better, or an official signal). It never changes - * the account's own status or alerts. - */ -export function CodexResetChip({ now }: { now: Date }) { - const preference = useCodexResetPreference(); - if (!preference.enabled) return null; - return ; -} - -function ForecastChip({ now }: { now: Date }) { - const t = useT(); - const theme = useTheme(); - const query = useCodexResetForecast(); - const entry = query.data; - const forecast = entry?.data; - if (!entry || !forecast || isDataStale(entry, forecast.updated_at, now.getTime())) return null; - const percent = forecast.probabilities.rounded_24h; - const band = chanceBand(percent); - const signal = !!forecast.official_signal; - if (!signal && (band === undefined || band === 'low')) return null; - const text = signal - ? t('Global reset signal') - : t('Global reset {percent}%', { percent: percent ?? 0 }); - return ( - - - {text} - - - ); -} - -const styles = StyleSheet.create({ - chip: { - borderWidth: 1, - borderRadius: Radius.pill, - paddingHorizontal: Spacing.two, - paddingVertical: 1, - }, -}); diff --git a/src/ui/codex-reset-section.tsx b/src/ui/codex-reset-section.tsx index 69aac4e..f20e90d 100644 --- a/src/ui/codex-reset-section.tsx +++ b/src/ui/codex-reset-section.tsx @@ -1,32 +1,11 @@ -import { useState } from 'react'; import { Alert, Linking, StyleSheet, View } from 'react-native'; import { Section } from '@/components/section'; import { ThemedText } from '@/components/themed-text'; -import { Radius, Spacing } from '@/constants/theme'; -import { formatAgo, formatCountdown } from '@/core/format'; -import { countdown } from '@/core/meter-utils'; -import type { Meter } from '@/core/types'; -import { - averageIntervalDays, - chanceBand, - chanceBandText, - confidenceLevel, - confidenceText, - errorText, - eventText, - localEventTime, - resetDays, -} from '@/data/codex-reset/format'; -import { useCodexReset } from '@/data/codex-reset/hooks'; +import { Spacing } from '@/constants/theme'; +import { chanceBand, chanceBandText, errorText, localEventTime } from '@/data/codex-reset/format'; +import { useCodexResetForecast } from '@/data/codex-reset/hooks'; import { useCodexResetPreference } from '@/data/codex-reset/preferences'; -import { - codexState, - isConfirmedReset, - newestFirst, - type Incident, - type ResetEvent, -} from '@/data/codex-reset/models'; import { isDataStale, SOURCE } from '@/data/codex-reset/service'; import { useTheme } from '@/hooks/use-theme'; import { useLocale, useT } from '@/i18n'; @@ -34,67 +13,11 @@ import { Button } from '@/ui/controls'; const open = (url: string) => void Linking.openURL(url).catch(() => {}); -function SourceCredit() { - return ( - open(SOURCE)}> - Data: codex-reset.com - - ); -} - -function EventRow({ event, now }: { event: ResetEvent; now: Date }) { - const t = useT(); - const locale = useLocale(); - return ( - - {eventText(event, t)} - - {localEventTime(event.announced_at, locale, now)} - - - {locale === 'zh' ? event.localized_summary || event.summary : event.summary} - - {event.url ? ( - open(event.url!)}> - {t('View announcement')} - - ) : null} - - ); -} - -function IncidentRow({ incident, now }: { incident: Incident; now: Date }) { - const t = useT(); - const locale = useLocale(); - const resolved = incident.status === 'resolved'; - const active = ['investigating', 'identified', 'monitoring'].includes(incident.status); - return ( - - {incident.name} - - {resolved ? t('Resolved') : active ? t('Ongoing incident') : t('Status unknown')} - - - {localEventTime(incident.started_at, locale, now)} - - {incident.source_url ? ( - open(incident.source_url!)} - > - {t('View status report')} - - ) : null} - - ); -} - /** * Auxiliary public information below the personal meters; it never changes their status. - * One card answers "is a global reset likely soon?" first, then shows the evidence. + * Only the source's global reset odds are shown: the next 24 and 48 hours. */ -export function CodexResetSection({ now, weekly }: { now: Date; weekly?: Meter }) { +export function CodexResetSection({ now }: { now: Date }) { const t = useT(); const preference = useCodexResetPreference(); const save = (enabled: boolean) => { @@ -115,7 +38,7 @@ export function CodexResetSection({ now, weekly }: { now: Date; weekly?: Meter } Alert.alert( t('Enable Codex Reset?'), t( - '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?', + 'Reset odds 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?', ), [ { text: t('Cancel'), style: 'cancel' }, @@ -126,7 +49,7 @@ export function CodexResetSection({ now, weekly }: { now: Date; weekly?: Meter } if (!preference.loaded) return null; if (!preference.enabled) { return ( -
+
{t( "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.", @@ -138,292 +61,50 @@ export function CodexResetSection({ now, weekly }: { now: Date; weekly?: Meter } disabled={preference.pending} onPress={() => change(true)} /> - open(`${SOURCE}developers`)} - > - {t('About the data source')} -
); } - return ( - change(false)} - /> - ); + return change(false)} />; } -function ResetRadar({ +function ResetChance({ now, - weekly, turningOff, onTurnOff, }: { now: Date; - weekly?: Meter; turningOff: boolean; onTurnOff: () => void; }) { const t = useT(); const locale = useLocale(); - const theme = useTheme(); - const queries = useCodexReset(); - const [more, setMore] = useState(false); - const forecast = queries.forecast.data?.data; - const timeline = queries.timeline.data?.data; - const status = queries.status.data?.data; - const events = newestFirst(timeline?.events ?? [], (e) => e.announced_at); - const confirmed = events.filter(isConfirmedReset); - const announcements = events.filter((e) => !isConfirmedReset(e)); - const incidents = newestFirst(status?.incidents ?? [], (e) => e.started_at); - const state = status ? codexState(status) : 'unknown'; - - const ms = now.getTime(); - const stale = [ - queries.forecast.data && isDataStale(queries.forecast.data, forecast?.updated_at, ms), - queries.timeline.data && isDataStale(queries.timeline.data, timeline?.updated_at, ms), - queries.status.data && - isDataStale(queries.status.data, status?.checked_at, ms, status?.stale), - ].some(Boolean); - const error = - queries.forecast.data?.error ?? queries.timeline.data?.error ?? queries.status.data?.error; - - const p24 = forecast?.probabilities.rounded_24h; - const p48 = forecast?.probabilities.rounded_48h; - const band = chanceBand(p24); - const dots = confidenceLevel(forecast?.confidence); - const days = resetDays(events, now); - const confirmedDays = days.filter((d) => d === 'confirmed').length; - const signalDays = days.filter((d) => d === 'signal').length; - const interval = averageIntervalDays(confirmed); - const lastReset = forecast?.last_reset_at ? Date.parse(forecast.last_reset_at) : NaN; - const personal = weekly ? countdown(weekly.resetsAt, now) : undefined; - const personalLeft = personal && !personal.elapsed ? personal : undefined; - const personalUsed = weekly?.kind.type === 'percent' ? Math.round(weekly.kind.used) : undefined; - const pct = (p: number | null | undefined) => (p == null ? t('Unknown') : `${p}%`); - const stateColor = - state === 'incident' ? theme.bad : state === 'operational' ? theme.good : theme.muted; + const query = useCodexResetForecast(); + const entry = query.data; + const forecast = entry?.data; + const stale = !!entry && isDataStale(entry, forecast?.updated_at, now.getTime()); return (
- - - {stale ? : null} - - {state === 'incident' ? ( - - {t('A platform incident is different from exhausted personal quota.')} - - ) : null} - {forecast ? ( - - - {t('Chance of a global reset in the next 24 hours')} - - - - {pct(p24)} - - - {chanceBandText(band, t)} - - - - - {t('48 hours: {percent}', { percent: pct(p48) })} - - - {[1, 2, 3].map((i) => ( - - ))} - - - {confidenceText(forecast.confidence, t)} - - - {forecast.confidence === 'low' ? ( - - {t('Low-confidence forecast: treat these odds with caution.')} - - ) : null} - - - {personalLeft ? ( - - {personalUsed === undefined - ? t('Your weekly quota resets on its own in {time}.', { - time: formatCountdown(personalLeft.label, t), - }) - : t('Your weekly quota is {used}% used and resets on its own in {time}.', { - used: personalUsed, - time: formatCountdown(personalLeft.label, t), - })} - - ) : null} - - {forecast.official_signal ? ( - - {t('Official signal reported')} - - {t('A signal is not a confirmed reset.')} - - open(SOURCE)} - > - {t('View announcement')} - - - ) : null} + + + ) : ( - {queries.forecast.isLoading ? t('Loading…') : t('No public data available.')} + {query.isLoading ? t('Loading…') : t('No public data available.')} )} - {timeline ? ( - - - - {t('Last 30 days')} - - - {t('{confirmed} confirmed · {signals} signals', { - confirmed: confirmedDays, - signals: signalDays, - })} - - - - {days.map((d, i) => ( - - ))} - - - ) : null} - - {Number.isFinite(lastReset) || interval !== undefined ? ( - - {Number.isFinite(lastReset) ? ( - - - {t('Last confirmed global reset')} - - - {`${formatAgo(lastReset, ms, t)} · ${localEventTime(forecast?.last_reset_at, locale, now)}`} - - - ) : null} - {interval !== undefined ? ( - - - {t('Average interval')} - - - {t('about {n} days', { n: interval })} - - - ) : null} - - ) : null} - - {timeline || status ? ( -
); } -function Chip({ text, color, dot = false }: { text: string; color: string; dot?: boolean }) { - return ( - - {dot ? : null} - - {text} - - - ); -} - -/** - * Now → 24 h → 48 h, shaded by the source's two odds, with the user's own weekly reset marked - * when it falls inside the two days (pinned to the end with an arrow when it is later). - */ -function ForecastStrip({ - p24, - p48, - personalHours, -}: { - p24?: number | null; - p48?: number | null; - personalHours?: number; -}) { +/** One window: the source's rounded percentage, large, with its band in words. */ +function Chance({ label, percent }: { label: string; percent: number | null | undefined }) { const t = useT(); const theme = useTheme(); - const shade = (p: number | null | undefined) => - p == null ? 0.15 : 0.15 + Math.min(1, Math.max(0, p) / 100) * 0.85; - const marker = personalHours === undefined ? undefined : Math.min(1, personalHours / 48); + const value = percent == null ? t('Unknown') : `${percent}%`; + const band = chanceBandText(chanceBand(percent), t); return ( - - - - {t('Now')} - - - 24h - - - 48h - - - - - - {marker === undefined ? null : ( - - )} - - {marker === undefined ? null : ( - 0.5 ? styles.alignEnd : null]} - > - {marker >= 1 ? t('Your weekly reset is later →') : t('▲ Your weekly reset')} + + + {label} + + + {value} + + {percent == null ? null : ( + + {band} )} @@ -530,42 +161,14 @@ function ForecastStrip({ const styles = StyleSheet.create({ number: { fontVariant: ['tabular-nums'] }, shrink: { flexShrink: 1 }, - block: { gap: Spacing.one }, - history: { gap: Spacing.three }, - chips: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.one }, - chip: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.one, - borderWidth: 1, - borderRadius: Radius.pill, - paddingHorizontal: Spacing.two, - paddingVertical: 1, - }, - answer: { flexDirection: 'row', alignItems: 'baseline', flexWrap: 'wrap', gap: Spacing.two }, - inline: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: Spacing.two }, - between: { + pair: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.four }, + chance: { flexGrow: 1, flexBasis: 120, gap: Spacing.half }, + notes: { gap: Spacing.one }, + source: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: Spacing.two, }, - dots: { flexDirection: 'row', gap: 3 }, - dot: { width: 6, height: 6, borderRadius: 3 }, - strip: { gap: Spacing.one, paddingTop: Spacing.one }, - track: { height: 12, flexDirection: 'row' }, - segment: { flex: 1, height: '100%' }, - first: { borderTopLeftRadius: Radius.pill, borderBottomLeftRadius: Radius.pill }, - second: { - marginLeft: 2, - borderTopRightRadius: Radius.pill, - borderBottomRightRadius: Radius.pill, - }, - marker: { position: 'absolute', top: -4, bottom: -4, width: 2, marginLeft: -1 }, - alignEnd: { alignSelf: 'flex-end' }, - days: { flexDirection: 'row', alignItems: 'center', gap: 2, height: 14 }, - day: { flex: 1, height: 6, borderRadius: 2 }, - dayConfirmed: { height: 12 }, - daySignal: { height: 10, borderWidth: 1.5 }, }); diff --git a/src/ui/header-button.tsx b/src/ui/header-button.tsx new file mode 100644 index 0000000..ef19eda --- /dev/null +++ b/src/ui/header-button.tsx @@ -0,0 +1,38 @@ +import { SymbolView, type SymbolViewProps } from 'expo-symbols'; +import { StyleSheet } from 'react-native'; + +import { useTheme } from '@/hooks/use-theme'; +import { haptics } from '@/ui/haptics'; +import { PressableScale } from '@/ui/motion'; + +/** Icon-only action for page headers, same size and feel as the refresh button. */ +export function HeaderButton({ + icon, + label, + onPress, +}: { + icon: SymbolViewProps['name']; + label: string; + onPress: () => void; +}) { + const theme = useTheme(); + return ( + { + haptics.tap(); + onPress(); + }} + style={styles.button} + > + + + ); +} + +const styles = StyleSheet.create({ + button: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/src/ui/meter-row.tsx b/src/ui/meter-row.tsx index 26f2ce8..b5cce38 100644 --- a/src/ui/meter-row.tsx +++ b/src/ui/meter-row.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { StyleSheet, useWindowDimensions, View } from 'react-native'; import { ThemedText } from '@/components/themed-text'; @@ -7,14 +8,9 @@ import type { Meter } from '@/core/types'; import { useT } from '@/i18n'; import type { AccountView } from '@/data/summary'; import { useTheme } from '@/hooks/use-theme'; -import { - forecastText, - meterTitle, - resetShort, - resetText, - useLevelColor, -} from '@/ui/meter-text'; +import { forecastText, meterTitle, resetShort, resetText, useLevelColor } from '@/ui/meter-text'; import { windowFor, WindowBar } from '@/ui/window-bar'; +import { Notice } from '@/ui/notice'; /** Above this font scale a one-line row stops fitting; the compact row stacks instead. */ const STACK_AT_FONT_SCALE = 1.3; @@ -54,12 +50,7 @@ export function MeterRow({ {reset} ) : null} - {forecast ? ( - - {'▲ '} - {forecast} - - ) : null} + {forecast ? {forecast} : null} ); } @@ -81,8 +72,6 @@ function CompactMeterRow({ }) { const t = useT(); const theme = useTheme(); - const { fontScale } = useWindowDimensions(); - const stacked = fontScale >= STACK_AT_FONT_SCALE; const title = meterTitle(meter, t); const fraction = usedFraction(meter); const window = windowFor(meter, t); @@ -93,20 +82,72 @@ function CompactMeterRow({ const valueColor = fraction !== undefined && fraction >= 0.6 ? color : theme.text; const label = [ title, - fraction === undefined ? `${window.label ?? t('used')} ${window.used}` : `${t('used')} ${value}`, + fraction === undefined + ? `${window.label ?? t('used')} ${window.used}` + : `${t('used')} ${value}`, reset, forecast, ] .filter(Boolean) .join(', '); + return ( + + {forecast ? {forecast} : null} + + ); +} + +/** + * The overview's one-line row: name, bar, value, a short trailing text (countdown or band). + * Shared by quota meters and the reset odds so their columns line up. Stacks into three lines + * at large font scales. + */ +export function CompactRow({ + title, + fraction, + color, + pace, + value, + valueColor, + trailing, + trailingLong, + accessibilityLabel, + children, +}: { + title: string; + /** 0..1 bar fill; no bar when undefined (balances) */ + fraction?: number; + color: string; + pace?: number; + value: string; + valueColor: string; + trailing?: string; + /** what the stacked layout prints instead of `trailing` (e.g. the full reset sentence) */ + trailingLong?: string; + accessibilityLabel: string; + children?: ReactNode; +}) { + const { fontScale } = useWindowDimensions(); + const stacked = fontScale >= STACK_AT_FONT_SCALE; const bar = fraction === undefined ? null : ( - + ); + const long = trailingLong ?? trailing; return ( - + {stacked ? ( @@ -118,9 +159,9 @@ function CompactMeterRow({ {bar} - {reset ? ( + {long ? ( - {reset} + {long} ) : null} @@ -149,16 +190,11 @@ function CompactMeterRow({ numberOfLines={1} style={[styles.number, styles.reset]} > - {short ?? ''} + {trailing ?? ''} )} - {forecast ? ( - - {'▲ '} - {forecast} - - ) : null} + {children} ); } @@ -170,7 +206,8 @@ const styles = StyleSheet.create({ stack: { gap: Spacing.one }, line: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, minHeight: 24 }, flex: { flex: 1 }, - title: { width: 76 }, + // a minimum, not a fixed width: a longer name widens its own column instead of being cut off + title: { minWidth: 88, flexShrink: 0 }, value: { minWidth: 40, textAlign: 'right' }, reset: { minWidth: 52, textAlign: 'right' }, }); diff --git a/src/ui/meter-text.ts b/src/ui/meter-text.ts index 8384d7a..0454b38 100644 --- a/src/ui/meter-text.ts +++ b/src/ui/meter-text.ts @@ -7,7 +7,9 @@ import type { UsageLevel } from '@/core/meter-utils'; import type { Translator } from '@/i18n'; export function meterTitle(m: Meter, t: Translator): string { - return m.scope.type === 'model' ? `${t(m.label)} · ${m.scope.name}` : t(m.label); + return m.scope.type === 'model' + ? t('{label} ({detail})', { label: t(m.label), detail: m.scope.name }) + : t(m.label); } export function resetText(m: Meter, now: Date, t: Translator): string | undefined { @@ -44,16 +46,12 @@ export function renewalInfo( view: AccountView, now: Date, t: Translator, -): { text: string; left: string; soon: boolean } | undefined { +): { text: string; soon: boolean } | undefined { const at = view.snapshot?.renewsAt ? Date.parse(view.snapshot.renewsAt) : NaN; if (!Number.isFinite(at) || at < now.getTime()) return undefined; const days = daysUntil(at, now.getTime()); const left = days <= 1 ? t('within a day') : t('{n} days left', { n: days }); - return { - text: t('Plan until {date} · {left}', { date: formatDate(at), left }), - left: t('Plan ends · {left}', { left }), - soon: days <= 7, - }; + return { text: t('Plan until {date} ({left})', { date: formatDate(at), left }), soon: days <= 7 }; } /** Level colour for a usage share; always paired with the printed figures. */ diff --git a/src/ui/notice.tsx b/src/ui/notice.tsx new file mode 100644 index 0000000..1ae697d --- /dev/null +++ b/src/ui/notice.tsx @@ -0,0 +1,60 @@ +import { SymbolView } from 'expo-symbols'; +import type { ReactNode } from 'react'; +import { StyleSheet, useWindowDimensions, View } from 'react-native'; + +import { ThemedText } from '@/components/themed-text'; +import { Spacing } from '@/constants/theme'; + +/** Line height of the `small` / `smallBold` text styles the notice sits next to. */ +const LINE = 20; +const ICON = 14; + +/** Circled exclamation mark: SF Symbol on iOS, Material Symbol on Android. Decorative only. */ +export function NoticeIcon({ color, size = ICON }: { color: string; size?: number }) { + return ( + + ); +} + +/** + * A hint or warning line: the notice icon, then wrapping text in the same color. The icon sits + * in a box exactly one text line tall and is centred in it, so it lines up with the first line + * on both platforms; box and icon follow the system font scale like the text does. + */ +export function Notice({ + color, + bold = false, + children, +}: { + color: string; + bold?: boolean; + children: ReactNode; +}) { + const { fontScale } = useWindowDimensions(); + const line = LINE * fontScale; + const size = Math.round(ICON * fontScale); + return ( + + + + + + {children} + + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.one }, + icon: { alignItems: 'center', justifyContent: 'center' }, + // shrink, not flex: in a content-sized parent (a card header) flex: 1 would collapse it + text: { flexShrink: 1, fontVariant: ['tabular-nums'] }, +}); diff --git a/src/ui/refresh-notice.tsx b/src/ui/refresh-notice.tsx index bb8ca2b..4370770 100644 --- a/src/ui/refresh-notice.tsx +++ b/src/ui/refresh-notice.tsx @@ -8,6 +8,7 @@ import type { RefreshSummary } from '@/data/refresh-summary'; import { useTheme } from '@/hooks/use-theme'; import { useT } from '@/i18n'; import { exitFade, layoutShift, Motion, PressableScale } from '@/ui/motion'; +import { Notice } from '@/ui/notice'; const SHOW_OK_MS = 2500; const SHOW_FAILED_MS = 7000; @@ -64,18 +65,16 @@ export function RefreshNotice({ : t('Updated {n} accounts', { n: summary.updated })} ) : summary.kind === 'timeout' ? ( - - {'▲ '} + {t('Refresh took too long. Some accounts may update a little later.')} - + ) : ( <> - - {'▲ '} + {summary.failed.length === 1 ? t('1 account could not be refreshed') : t('{n} accounts could not be refreshed', { n: summary.failed.length })} - + {summary.failed.map((f) => ( diff --git a/src/ui/reset-chance-line.tsx b/src/ui/reset-chance-line.tsx new file mode 100644 index 0000000..571133e --- /dev/null +++ b/src/ui/reset-chance-line.tsx @@ -0,0 +1,63 @@ +import { View } from 'react-native'; + +import { ThemedText } from '@/components/themed-text'; +import { Spacing } from '@/constants/theme'; +import { chanceBand, chanceBandText } from '@/data/codex-reset/format'; +import { useCodexResetForecast } from '@/data/codex-reset/hooks'; +import { useCodexResetPreference } from '@/data/codex-reset/preferences'; +import { isDataStale } from '@/data/codex-reset/service'; +import { useTheme } from '@/hooks/use-theme'; +import { useT } from '@/i18n'; +import { CompactRow } from '@/ui/meter-row'; +import { Notice } from '@/ui/notice'; + +/** + * The Codex global reset odds on the usage page, as two rows in the same grid as the quota rows: + * "24H reset chance ██░░░ 22% Low". Accent-coloured bars, so they never read as quota used. + * Only after opt-in and once odds are cached; never changes an account's status. + */ +export function ResetChanceLine({ now, heading }: { now: Date; heading?: string }) { + const preference = useCodexResetPreference(); + if (!preference.enabled) return null; + return ; +} + +/** `heading` is shown above the rows, and only when there are odds to show. */ +function Odds({ now, heading }: { now: Date; heading?: string }) { + const t = useT(); + const theme = useTheme(); + const entry = useCodexResetForecast().data; + const forecast = entry?.data; + if (!entry || !forecast) return null; + const stale = isDataStale(entry, forecast.updated_at, now.getTime()); + const rows = [ + [t('24H reset chance'), forecast.probabilities.rounded_24h], + [t('48H reset chance'), forecast.probabilities.rounded_48h], + ] as const; + return ( + + {heading ? ( + + {heading} + + ) : null} + {rows.map(([title, percent]) => { + const value = percent == null ? '—' : `${percent}%`; + const band = percent == null ? undefined : chanceBandText(chanceBand(percent), t); + return ( + + ); + })} + {stale ? {t('Outdated data')} : null} + + ); +} diff --git a/src/ui/status-badge.tsx b/src/ui/status-badge.tsx index 39848a9..7d5a7d3 100644 --- a/src/ui/status-badge.tsx +++ b/src/ui/status-badge.tsx @@ -3,11 +3,11 @@ import Animated from 'react-native-reanimated'; import { ThemedText } from '@/components/themed-text'; import { formatAgo } from '@/core/format'; -import { Radius, Spacing } from '@/constants/theme'; import type { DisplayStatus } from '@/data/summary'; import { useTheme } from '@/hooks/use-theme'; import { useT, type Translator } from '@/i18n'; import { enterFade, exitFade } from '@/ui/motion'; +import { Notice } from '@/ui/notice'; /** Text for a status, so tests and screens agree on wording. */ export function statusLabel(s: DisplayStatus, now: Date, t: Translator): string | undefined { @@ -26,23 +26,24 @@ export function statusLabel(s: DisplayStatus, now: Date, t: Translator): string return t('Unsupported'); case 'error': return s.lastSuccessAt - ? t('Error · data from {ago}', { ago: formatAgo(s.lastSuccessAt, now.getTime(), t) }) + ? t('Error, data from {ago}', { ago: formatAgo(s.lastSuccessAt, now.getTime(), t) }) : t('Error'); } } -// One glyph family for every status: same monochrome geometric set as the meter level symbols -// (● ▲ ■ ○), so no emoji rendering drifts between platforms. +// Problems get the notice icon; the rest keep a plain monochrome glyph, so no emoji rendering +// drifts between platforms. const glyph: Record = { ok: '', manual: '▤', pending: '…', - authExpired: '⊘', - unsupported: '▲', - error: '▲', + authExpired: '', + unsupported: '', + error: '', stale: '', }; +/** Status as plain text (no pill or border): a glyph and the label, red for problems. */ export function StatusBadge({ status, now }: { status: DisplayStatus; now: Date }) { const theme = useTheme(); const t = useT(); @@ -56,23 +57,21 @@ export function StatusBadge({ status, now }: { status: DisplayStatus; now: Date key={status.kind} entering={enterFade} exiting={exitFade} - style={[styles.badge, { borderColor: color }]} + style={styles.badge} accessible accessibilityLabel={label} > - - {glyph[status.kind]} {label} - + {bad ? ( + {label} + ) : ( + + {glyph[status.kind]} {label} + + )} ); } const styles = StyleSheet.create({ - badge: { - alignSelf: 'flex-start', - borderWidth: 1, - borderRadius: Radius.pill, - paddingHorizontal: Spacing.two, - paddingVertical: 1, - }, + badge: { alignSelf: 'flex-start' }, }); diff --git a/src/ui/update-notice.tsx b/src/ui/update-notice.tsx new file mode 100644 index 0000000..dcb060d --- /dev/null +++ b/src/ui/update-notice.tsx @@ -0,0 +1,54 @@ +import { Linking, StyleSheet, View } from 'react-native'; + +import { ThemedText } from '@/components/themed-text'; +import { Spacing } from '@/constants/theme'; +import type { LatestRelease } from '@/data/update-check'; +import { useT } from '@/i18n'; +import { haptics } from '@/ui/haptics'; + +/** + * New-version notice as plain text under the page title (no pill): the version opens the release + * page; "Don't show again" dismisses this version for good. + */ +export function UpdateNotice({ + release, + onDismiss, +}: { + release: LatestRelease; + onDismiss: () => void; +}) { + const t = useT(); + return ( + + { + haptics.tap(); + void Linking.openURL(release.url); + }} + > + {t('New version {version} available', { version: release.version })} + + { + haptics.select(); + onDismiss(); + }} + > + {t('Dismiss this version')} + + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', columnGap: Spacing.three }, + text: { lineHeight: 30 }, +}); diff --git a/src/ui/update-pill.tsx b/src/ui/update-pill.tsx deleted file mode 100644 index 497966b..0000000 --- a/src/ui/update-pill.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { SymbolView } from 'expo-symbols'; -import { Linking, StyleSheet, View } from 'react-native'; -import Animated from 'react-native-reanimated'; - -import { ThemedText } from '@/components/themed-text'; -import { Radius, Spacing } from '@/constants/theme'; -import type { LatestRelease } from '@/data/update-check'; -import { useTheme } from '@/hooks/use-theme'; -import { useT } from '@/i18n'; -import { haptics } from '@/ui/haptics'; -import { enterFade, exitFade, PressableScale } from '@/ui/motion'; - -/** - * "New version" pill beside a page title: tapping it opens the release page, the ✕ dismisses this - * version for good. - */ -export function UpdatePill({ - release, - onDismiss, -}: { - release: LatestRelease; - onDismiss: () => void; -}) { - const theme = useTheme(); - const t = useT(); - return ( - - { - haptics.tap(); - void Linking.openURL(release.url); - }} - style={styles.label} - > - - {t('New v{version}', { version: release.version })} - - - - { - haptics.select(); - onDismiss(); - }} - style={styles.close} - > - - - - ); -} - -const styles = StyleSheet.create({ - pill: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: Radius.pill, - paddingLeft: Spacing.two + 2, - paddingRight: Spacing.one, - minHeight: 26, - flexShrink: 1, - }, - label: { paddingVertical: 3, flexShrink: 1 }, - divider: { width: StyleSheet.hairlineWidth * 2, height: 12, opacity: 0.4, marginHorizontal: 6 }, - close: { width: 20, height: 20, alignItems: 'center', justifyContent: 'center' }, -}); diff --git a/src/widgets/sync.ts b/src/widgets/sync.ts index 2e0b4ce..d249f7a 100644 --- a/src/widgets/sync.ts +++ b/src/widgets/sync.ts @@ -16,7 +16,10 @@ const SHORT: Record = { }; function toWidgetMeter(m: Meter, t: typeof translate): WidgetMeter { - const label = m.scope.type === 'model' ? `${t(m.label)} · ${m.scope.name}` : t(m.label); + const label = + m.scope.type === 'model' + ? t('{label} ({detail})', { label: t(m.label), detail: m.scope.name }) + : t(m.label); const reset = m.resetsAt ? Date.parse(m.resetsAt) : NaN; return { label,