diff --git a/.github/changes/v1.0.5.md b/.github/changes/v1.0.5.md new file mode 100644 index 0000000..f0bf36f --- /dev/null +++ b/.github/changes/v1.0.5.md @@ -0,0 +1,14 @@ +## v1.0.5 更新内容 + +### 改进 + +- **紧凑用量卡片**:用量页每个账号的头部压缩为一行(图标、名称、套餐、状态);每个额度只占一行:名称、进度条、已用百分比和重置倒计时。同样的屏幕可以看到更多服务商。剩余额度和完整重置时间仍在详情页显示。 +- **节奏线**:进度条上的细竖线表示「按平均速度此刻应用到的位置」,进度超过竖线说明用得比平均快。适用于 5 小时、每日、每周和每月窗口。 +- **大字体自动换行**:系统字体放大到 1.3 倍及以上时,每个额度自动改为三行显示,保证可读性。 +- **套餐到期提示**:卡片上只在到期前 7 天内显示,其余时间在详情页查看。 +- **全局重置雷达**:Codex 详情页的预测、事件和服务状态合并为一张卡。先给出未来 24 小时全局重置的可能性(较低 / 有可能 / 较高)和百分比,再展示 48 小时概率、置信度、标出你自己周额度重置时间的时间线、近 30 天重置记录和平均间隔。历史和故障信息可展开查看,数据来源只显示一次。 +- **用量页重置提示**:开启全局重置雷达后,若未来 24 小时全局重置的可能性达到 15% 及以上,或出现官方信号,Codex 卡片会显示「全局重置 N%」或「全局重置信号」标签。该标签不影响账号状态和提醒。 + +### 使用说明 + +- 全局重置数据来自独立第三方 codex-reset.com,默认关闭,开启前需确认;预测不保证你的个人额度恢复。 diff --git a/app.json b/app.json index 55136ac..a693614 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "AI Usage", "slug": "usage", - "version": "1.0.4", + "version": "1.0.5", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "usage", diff --git a/docs/codex-reset.md b/docs/codex-reset.md index e472325..f032510 100644 --- a/docs/codex-reset.md +++ b/docs/codex-reset.md @@ -72,21 +72,40 @@ controlled response variations, not a claim that a live outage was observed. - Public 401/403 is a data-source failure, never a rejected ChatGPT credential. Public data failure is never evidence of an OpenAI outage. Stale status is explicitly labelled as old data. -Each of the three data sections displays **Data: codex-reset.com**, linked to - on that same surface. README and this document carry the credit too. -Do not replace the on-screen credit with an About-page link. No data is republished via an API. +The radar card displays **Data: codex-reset.com** once, linked to , +next to the source update time; it covers forecast, history and status on that same surface. +README and this document carry the credit too. Do not replace the on-screen credit with an +About-page link. No data is republished via an API. ## UI and alerts -The module reuses `Section`, `ToggleRow`, `Button`, theme colors and existing motion. -Information is displayed in one column, including account headings and shared personal meters. -Forecast percentages use theme-accent text, not personal quota bars. Personal meters keep their -existing calculation and animation with used amount, bar, remaining amount and reset stacked. -Confirmed history defaults to two rows; other announcements appear only when expanded. Expansion is bounded to -ten confirmed and five other rows; the complete history remains available at the source. -Operational status is compact; recent incidents are expandable. All interface copy is English -and Chinese; `Intl.DateTimeFormat` follows the app language and device timezone. Today/yesterday -use local calendar dates, with years shown for older-year events. +Everything lives in one "Global reset radar" section on the Codex details screen, built from +`Section`, `Button`, theme colors and existing motion. It answers first and shows evidence after: + +- Status chips: Codex operational / incident / unknown, plus "Outdated data" when any endpoint + is stale. An incident adds the line that it differs from exhausted personal quota. +- The 24-hour chance as a large accent number with a word band (`chanceBand`: under 15% low, + 15-39% possible, 40% and up high), then the 48-hour chance and confidence as 1-3 dots plus text. + Low confidence keeps its explicit warning. +- A Now / 24h / 48h strip shaded by the two source odds. When the account's own `weekly` meter + has a future reset, a marker sits at its position (pinned to the end with an arrow past 48 h), + and a sentence states its used share and countdown. Odds are never extrapolated to that time. +- A 30-day row of local calendar days: filled for a confirmed reset, outlined for an unconfirmed + `reset` signal; other groups are not marked. Last confirmed reset (relative and absolute) and + the average interval between confirmed resets (needs two) follow. +- "History and service status" expands up to ten confirmed and five other announcements and + three incidents; the complete history remains available at the source. +- One footnote: fetch error (once for the card), stale warning, source time and credit, and the + "Turn off" link. Off, the section is a short explanation and a "Turn on" button that opens the + same consent dialog. + +On the overview, a Codex card shows a "Global reset N%" chip only after opt-in, with fresh +forecast data, and when the 24-hour band is possible or higher; an official signal shows +"Global reset signal". It polls under the same focus/foreground gates and cache as the details +screen and never changes the account's status or alerts. + +All interface copy is English and Chinese; `Intl.DateTimeFormat` follows the app language and +device timezone. Today/yesterday use local calendar dates, with years shown for older-year events. Global reset notifications are intentionally deferred. Existing alerts are account/meter-cycle based. A global subscription needs a persistent first-enable baseline, event-ID deduplication diff --git a/src/app/account/[id].tsx b/src/app/account/[id].tsx index ce5b293..541bd8a 100644 --- a/src/app/account/[id].tsx +++ b/src/app/account/[id].tsx @@ -277,7 +277,12 @@ export default function AccountDetailScreen() { )} - {v.account.providerId === 'codex' ? : null} + {v.account.providerId === 'codex' ? ( + m.id === 'weekly' && m.scope.type === 'overall')} + /> + ) : null} { ); }); }); + +describe('elapsedFraction', () => { + const now = new Date('2026-10-06T12:00:00Z'); + it('places the pace tick by the known window length of shared meter ids', () => { + // 5-hour window resetting in 2 h: 3 of 5 hours gone + expect(elapsedFraction(pct('session', 10, '2026-10-06T14:00:00Z'), now)).toBeCloseTo(0.6); + // weekly window resetting in 3.5 days: half gone + expect(elapsedFraction(pct('weekly', 10, '2026-10-10T00:00:00Z'), now)).toBeCloseTo(0.5); + // calendar month: 10 Sep -> 10 Oct, 26 of 30 days gone + expect(elapsedFraction(pct('monthly', 10, '2026-10-10T12:00:00Z'), now)).toBeCloseTo(26 / 30); + }); + + it('draws nothing without a known window or a future reset', () => { + expect(elapsedFraction(pct('points', 10, '2026-10-07T00:00:00Z'), now)).toBeUndefined(); + expect(elapsedFraction(pct('session', 10), now)).toBeUndefined(); + expect(elapsedFraction(pct('session', 10, '2026-10-06T11:00:00Z'), now)).toBeUndefined(); + // a reset further away than the window means the guess is wrong: no tick + expect(elapsedFraction(pct('session', 10, '2026-10-07T12:00:00Z'), now)).toBeUndefined(); + }); +}); diff --git a/src/core/meter-utils.ts b/src/core/meter-utils.ts index 18aedf1..11d0d92 100644 --- a/src/core/meter-utils.ts +++ b/src/core/meter-utils.ts @@ -98,3 +98,33 @@ export function cycleKey(accountId: string, m: Meter): string { const scope = m.scope.type === 'model' ? `:${m.scope.name}` : ''; return `${accountId}:${m.id}${scope}:${m.resetsAt ?? 'none'}`; } + +const HOUR = 3_600_000; +/** Window length by the meter ids providers share; calendar-month windows are handled apart. */ +const FIXED_WINDOWS: Record = { + session: 5 * HOUR, + weekly: 7 * 24 * HOUR, + usage_weekly: 7 * 24 * HOUR, + today: 24 * HOUR, + usage_daily: 24 * HOUR, +}; +const MONTHLY = new Set(['monthly', 'month', 'usage_monthly', 'premium', 'chat', 'completions']); + +/** + * How far through its window a meter is, 0..1: where an even pace would have the bar now. + * Undefined when the window length is not known for this meter id or the reset is missing/past. + */ +export function elapsedFraction(m: Meter, now: Date): number | undefined { + const end = m.resetsAt ? Date.parse(m.resetsAt) : NaN; + if (!Number.isFinite(end) || end <= now.getTime()) return undefined; + let start: number; + const fixed = FIXED_WINDOWS[m.id]; + if (fixed) start = end - fixed; + else if (MONTHLY.has(m.id)) { + const d = new Date(end); + d.setMonth(d.getMonth() - 1); + start = d.getTime(); + } else return undefined; + const f = (now.getTime() - start) / (end - start); + return f >= 0 && f <= 1 ? f : undefined; +} diff --git a/src/data/codex-reset/format.ts b/src/data/codex-reset/format.ts index 13ef6f1..23139a1 100644 --- a/src/data/codex-reset/format.ts +++ b/src/data/codex-reset/format.ts @@ -51,3 +51,63 @@ export function errorText(error: DataError | undefined, t: Translator): string { if (error === 'invalid') return t('Codex Reset returned unrecognized data.'); return t('Codex Reset data unavailable. This does not indicate a Codex outage.'); } + +export type ChanceBand = 'low' | 'possible' | 'high'; + +/** Words before numbers: rounded percentage (0..100) to a band. */ +export function chanceBand(percent: number | null | undefined): ChanceBand | undefined { + if (percent == null || !Number.isFinite(percent)) return undefined; + if (percent >= 40) return 'high'; + if (percent >= 15) return 'possible'; + return 'low'; +} + +export function chanceBandText(band: ChanceBand | undefined, t: Translator): string { + if (band === 'high') return t('High'); + if (band === 'possible') return t('Possible'); + if (band === 'low') return t('Low'); + return t('Unknown'); +} + +/** Confidence as filled dots out of three; undefined when the source gives no known level. */ +export function confidenceLevel(value: string | null | undefined): 1 | 2 | 3 | undefined { + if (value === 'low') return 1; + if (value === 'medium') return 2; + if (value === 'high') return 3; + return undefined; +} + +/** Mean days between consecutive confirmed resets; needs at least two. */ +export function averageIntervalDays(confirmed: ResetEvent[]): number | undefined { + const times = confirmed + .map((e) => Date.parse(e.announced_at)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + if (times.length < 2) return undefined; + const span = times[times.length - 1] - times[0]; + return Math.max(1, Math.round(span / (times.length - 1) / 86_400_000)); +} + +export type DayMark = 'confirmed' | 'signal' | undefined; + +/** + * One mark per local calendar day, oldest first, ending today. A confirmed reset outranks an + * unconfirmed reset signal on the same day; other announcement groups are not marked. + */ +export function resetDays(events: ResetEvent[], now: Date, days = 30): DayMark[] { + const marks: DayMark[] = Array.from({ length: days }, () => undefined); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + for (const e of events) { + if (e.group !== 'reset') continue; + const at = new Date(Date.parse(e.announced_at)); + if (!Number.isFinite(at.getTime())) continue; + const day = new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime(); + // calendar days, rounded so a daylight-saving shift does not drop a day + const back = Math.round((today - day) / 86_400_000); + if (back < 0 || back >= days) continue; + const i = days - 1 - back; + if (isConfirmedReset(e)) marks[i] = 'confirmed'; + else if (marks[i] !== 'confirmed') marks[i] = 'signal'; + } + return marks; +} diff --git a/src/data/codex-reset/hooks.ts b/src/data/codex-reset/hooks.ts index 6f02670..a7466ff 100644 --- a/src/data/codex-reset/hooks.ts +++ b/src/data/codex-reset/hooks.ts @@ -20,7 +20,8 @@ export function resetQuery(service: CodexResetService, endpoint: Endpoint) { }; } -export function useCodexReset() { +/** Polls only while the screen is focused and the app is in the foreground, and only after opt-in. */ +function usePollOptions() { const service = useServices().data?.codexReset; const preference = useCodexResetPreference(); const [focused, setFocused] = useState(false); @@ -35,14 +36,21 @@ export function useCodexReset() { const sub = AppState.addEventListener('change', (state) => setActive(state === 'active')); return () => sub.remove(); }, []); - const options = { - enabled: !!service && preference.enabled && focused && active, - staleTime: CACHE_MS, - gcTime: 24 * 60 * 60_000, - retry: false as const, - refetchInterval: CACHE_MS, - refetchIntervalInBackground: false, + return { + service, + options: { + enabled: !!service && preference.enabled && focused && active, + staleTime: CACHE_MS, + gcTime: 24 * 60 * 60_000, + retry: false as const, + refetchInterval: CACHE_MS, + refetchIntervalInBackground: false, + }, }; +} + +export function useCodexReset() { + const { service, options } = usePollOptions(); return { forecast: useQuery({ ...options, @@ -62,6 +70,16 @@ export function useCodexReset() { }; } +/** Forecast alone, for the overview card; shares the cache and gates of the details screen. */ +export function useCodexResetForecast() { + const { service, options } = usePollOptions(); + return useQuery({ + ...options, + queryKey: resetKey('forecast'), + queryFn: () => service!.load('forecast'), + }); +} + /** fetchQuery honours the same cache as mount/poll; the service also gates background reads. */ export function useRefreshCodexReset() { const service = useServices().data?.codexReset; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 77063f9..703133e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -3,10 +3,37 @@ // A test enforces coverage. export const zh: Record = { // Codex Reset public data - 'Show global reset forecast': '显示全局重置预测', - 'Also shows public reset history and service status. Applies to all Codex accounts.': - '同时显示公共重置历史和服务状态,对所有 Codex 账号生效。', - 'Off. No requests are sent to codex-reset.com.': '已关闭,不会向 codex-reset.com 发起请求。', + 'Global reset radar': '全局重置雷达', + "See how likely OpenAI is to reset everyone's Codex limits early. Data comes from codex-reset.com, an independent site; you confirm before anything is requested.": + '查看 OpenAI 为所有人提前重置 Codex 额度的可能性。数据来自独立网站 codex-reset.com,发起任何请求前都会先征得你的同意。', + 'Turn on': '开启', + 'Turn off global reset radar': '关闭全局重置雷达', + 'Chance of a global reset in the next 24 hours': '未来 24 小时发生全局重置的可能性', + Low: '较低', + Possible: '有可能', + High: '较高', + '48 hours: {percent}': '48 小时:{percent}', + Now: '现在', + '▲ Your weekly reset': '▲ 你的周额度重置', + 'Your weekly reset is later →': '你的周额度重置更晚 →', + 'Your weekly quota resets on its own in {time}.': '你的周额度将在 {time}后自行重置。', + 'Your weekly quota is {used}% used and resets on its own in {time}.': + '你的周额度已用 {used}%,将在 {time}后自行重置。', + 'Last 30 days': '近 30 天', + '{confirmed} confirmed · {signals} signals': '已确认 {confirmed} 天 · 信号 {signals} 天', + 'Last 30 days: {confirmed} days with a confirmed reset, {signals} with an unconfirmed signal': + '近 30 天:{confirmed} 天有已确认重置,{signals} 天有未确认信号', + 'Average interval': '平均间隔', + 'about {n} days': '约 {n} 天', + 'History and service status': '历史与服务状态', + 'Outdated data': '数据已过期', + 'Global reset {percent}%': '全局重置 {percent}%', + 'Global reset signal': '全局重置信号', + 'Global reset signal reported, not confirmed': '已报告全局重置信号,尚未确认', + 'Global reset chance in 24 hours: {percent}%': '24 小时内全局重置的可能性:{percent}%', + // compact meter rows + Due: '待重置', + 'Plan ends · {left}': '方案到期 · {left}', 'About the data source': '了解数据来源', 'Enable Codex Reset?': '开启 Codex Reset?', 'Forecasts, global events and service status are fetched directly from codex-reset.com, an independent service, not OpenAI. Your API keys, ChatGPT access tokens, account IDs and personal usage are never sent to this site. Predictions do not guarantee your personal quota will recover. Allow public requests while this switch is on, including background refresh when available?': @@ -17,8 +44,6 @@ export const zh: Record = { 'View status report': '查看故障报告', 'Today {time}': '今天 {time}', 'Yesterday {time}': '昨天 {time}', - 'Global reset forecast': '全局重置预测', - 'Extra reset chance in {hours} hours': '未来 {hours} 小时额外重置概率', Probability: '概率', Unknown: '未知', 'Low confidence': '低置信度', @@ -31,7 +56,6 @@ export const zh: Record = { 'A signal is not a confirmed reset.': '信号不代表重置已确认发生。', 'Independent prediction, not an OpenAI quota API. A global reset does not guarantee your personal quota will recover.': '独立第三方预测,并非 OpenAI 官方额度接口。全局重置不保证你的个人额度恢复。', - 'Recent global events': '近期全局事件', 'Unconfirmed signals and other announcements are not confirmed global resets.': '未确认信号和其他公告不代表已确认的全局重置。', 'Confirmed reset': '已确认重置', @@ -42,15 +66,12 @@ export const zh: Record = { 'Other announcement': '其他公告', 'No confirmed resets reported.': '暂无已确认的重置记录。', 'View announcement': '查看公告', - 'More history': '更多历史记录', 'Show less': '收起', - 'Codex service status': 'Codex 服务状态', 'Codex operational': 'Codex 运行正常', 'Codex service incident reported': '来源报告了 Codex 服务故障', 'Codex status unknown': 'Codex 状态未知', 'A platform incident is different from exhausted personal quota.': '平台服务故障与个人额度耗尽是不同的问题。', - 'Recent incidents': '近期故障', 'Ongoing incident': '故障处理中', 'Status unknown': '状态未知', Resolved: '已恢复', diff --git a/src/ui/__tests__/codex-reset-section.test.tsx b/src/ui/__tests__/codex-reset-section.test.tsx index 4c0b55f..fd707bd 100644 --- a/src/ui/__tests__/codex-reset-section.test.tsx +++ b/src/ui/__tests__/codex-reset-section.test.tsx @@ -1,25 +1,39 @@ 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 } from '@/data/codex-reset/hooks'; +import { useCodexReset, useCodexResetForecast } from '@/data/codex-reset/hooks'; import { useCodexResetPreference } from '@/data/codex-reset/preferences'; -import { confidenceText, eventText, localEventTime } from '@/data/codex-reset/format'; +import { + averageIntervalDays, + chanceBand, + confidenceText, + eventText, + localEventTime, + resetDays, +} 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 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() })); +jest.mock('@/data/codex-reset/hooks', () => ({ + useCodexReset: jest.fn(), + 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 }, isLoading: false, }); +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); beforeEach(() => { setLocale('en'); @@ -41,35 +55,64 @@ afterEach(() => { jest.restoreAllMocks(); }); -it('renders genuine probabilities, low confidence, confirmation labels and clickable source on each surface', async () => { +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 () => { const open = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); - await render(); + await render(); + expect(screen.getByText('Chance of a global reset in the next 24 hours')).toBeTruthy(); expect(screen.getByText('15%')).toBeTruthy(); - expect(screen.getByText('28%')).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.getAllByText('Confirmed reset')).toHaveLength(2); + 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(); 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('Recent incidents')); + 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(3); - await fireEvent.press(screen.getAllByRole('link', { name: 'Data: codex-reset.com' })[0]); + expect(screen.getAllByRole('link', { name: 'Data: codex-reset.com' })).toHaveLength(1); + 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 () => { setLocale('zh'); await render(); - expect(screen.getByText('全局重置预测')).toBeTruthy(); + expect(screen.getByText('全局重置雷达')).toBeTruthy(); expect(screen.getByText('15%')).toBeTruthy(); - expect(screen.getAllByText('已确认重置')).toHaveLength(2); - // Before expansion, last-confirmed and its timeline entry share the same local time. + expect(screen.getByText('有可能')).toBeTruthy(); expect( - screen.getAllByText(localEventTime(timeline.events[4].announced_at, 'zh', NOW)), - ).toHaveLength(2); + screen.getByText(new RegExp(escape(localEventTime(forecast.last_reset_at, 'zh', NOW)))), + ).toBeTruthy(); expect(screen.queryByText(timeline.events[0].localized_summary!)).toBeNull(); - await fireEvent.press(screen.getByText('更多历史记录')); + await fireEvent.press(screen.getByText('历史与服务状态')); + expect(screen.getAllByText('已确认重置')).toHaveLength(3); expect(screen.getByText(timeline.events[0].localized_summary!)).toBeTruthy(); expect(screen.getByText('Codex 运行正常')).toBeTruthy(); }); @@ -87,13 +130,17 @@ it('degrades missing probabilities and signals, warns on old cached data without }; 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(2); + ).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(); }); @@ -121,7 +168,7 @@ it('does not present a type=reset event as confirmed and renders an official sig }); q.forecast.data.data.official_signal = { future_field: 'unknown' }; await render(); - await fireEvent.press(screen.getByText('More history')); + 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(); @@ -166,7 +213,7 @@ it.each(['en', 'zh'] as const)( await render(); expect(useReset).not.toHaveBeenCalled(); expect(screen.queryByText('15%')).toBeNull(); - await fireEvent(screen.getByRole('switch'), 'valueChange', true); + await fireEvent.press(screen.getByText(locale === 'zh' ? '开启' : 'Turn on')); const [, message, buttons] = alert.mock.calls[0]; expect(message).toContain('codex-reset.com'); expect(message).toContain('OpenAI'); @@ -184,7 +231,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(screen.getByRole('switch'), 'valueChange', false); + await fireEvent.press(screen.getByText('Turn off global reset radar')); expect(save).toHaveBeenCalledWith(false); await waitFor(() => expect(alert).toHaveBeenCalledWith( @@ -216,3 +263,59 @@ it('formats today and yesterday using local calendar dates across a year boundar ); } }); + +it('bands probabilities, marks 30 local days and averages confirmed intervals', () => { + expect([null, 0, 14, 15, 39, 40, 100].map(chanceBand)).toEqual([ + undefined, + 'low', + 'low', + 'possible', + 'possible', + '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(); + }); + + 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(); + + usePreference.mockReturnValue({ enabled: false, loaded: true, pending: false }); + useForecast.mockClear(); + await render(); + expect(screen.queryByText(/Global reset/)).toBeNull(); + 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 5ee55cd..2b555cb 100644 --- a/src/ui/__tests__/components.test.tsx +++ b/src/ui/__tests__/components.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; +import { Dimensions } from 'react-native'; import type { Meter, UsageSnapshot } from '@/core/types'; import type { AccountView } from '@/data/summary'; @@ -63,14 +64,34 @@ describe('AccountCard', () => { expect(screen.getByText('GLM Coding Plan (z.ai)')).toBeTruthy(); expect(screen.getByText('Work · Pro')).toBeTruthy(); expect(screen.getByText(/5-hour window/)).toBeTruthy(); - expect(screen.getByText('Resets in 3h 12m')).toBeTruthy(); expect(screen.getByText(/Weekly/)).toBeTruthy(); - // every window gets its own bar, with the used and remaining share printed beside it - expect(screen.getByText('used 72%')).toBeTruthy(); - expect(screen.getByText('Remaining 90%')).toBeTruthy(); + // one line per window: used share and a short countdown; remaining is left to the details + expect(screen.getByText('72%')).toBeTruthy(); + expect(screen.getByText('10%')).toBeTruthy(); + // the test renderer reports a large font scale, so rows stack with the full countdown + expect(screen.getByText('Resets in 3h 12m')).toBeTruthy(); + expect(screen.queryByText('Remaining 90%')).toBeNull(); + // screen readers still get the full sentence + expect(screen.getByLabelText('5-hour window, used 72%, Resets in 3h 12m')).toBeTruthy(); expect(screen.queryByText('Login expired')).toBeNull(); }); + it('fits each window on one line at normal font sizes', async () => { + const window = Dimensions.get('window'); + Dimensions.set({ window: { ...window, fontScale: 1 } }); + try { + const v = view({ + snapshot: snapshot([pct('session', 72, '2026-10-06T15:12:00Z')]), + }); + await render( {}} />); + expect(screen.getByText('72%')).toBeTruthy(); + expect(screen.getByText('3h12m')).toBeTruthy(); + expect(screen.queryByText('Resets in 3h 12m')).toBeNull(); + } finally { + Dimensions.set({ window }); + } + }); + it('keeps showing the last data without a staleness badge', async () => { const v = view({ snapshot: snapshot([pct('session', 40)]), diff --git a/src/ui/account-card-skeleton.tsx b/src/ui/account-card-skeleton.tsx index 6ad2af5..a4d4542 100644 --- a/src/ui/account-card-skeleton.tsx +++ b/src/ui/account-card-skeleton.tsx @@ -7,7 +7,7 @@ import { useT } from '@/i18n'; import { useBreathe } from '@/ui/skeleton'; /** - * Placeholder that mirrors the AccountCard's single-column header and meter while the first + * Placeholder that mirrors the AccountCard's one-line header and meter rows while the first * snapshot loads, with the shared breathing pulse (`useBreathe`). It reports no progress value, * since none is known yet. */ @@ -22,27 +22,28 @@ export function AccountCardSkeleton() { - - - - - - - - + + {[0, 1].map((i) => ( + + + + + + ))} ); } const styles = StyleSheet.create({ root: { gap: Spacing.two }, - header: { gap: Spacing.two, alignItems: 'flex-start' }, - body: { gap: Spacing.one, paddingTop: Spacing.one }, + header: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two }, + meter: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, minHeight: 24 }, line: { height: 12, borderRadius: Radius.pill }, - title: { width: 132 }, - wide: { width: '72%' }, - narrow: { width: '44%' }, - icon: { width: 24, height: 24, borderRadius: Radius.control }, - track: { width: '100%', height: 8, borderRadius: Radius.pill }, + title: { width: 96 }, + plan: { width: 48 }, + label: { width: 76 }, + value: { width: 96 }, + icon: { width: 20, height: 20, borderRadius: Radius.control }, + track: { flex: 1, height: 6, borderRadius: Radius.pill }, }); diff --git a/src/ui/account-card.tsx b/src/ui/account-card.tsx index b186248..70287c1 100644 --- a/src/ui/account-card.tsx +++ b/src/ui/account-card.tsx @@ -1,7 +1,7 @@ import { StyleSheet, View } from 'react-native'; import { ThemedText } from '@/components/themed-text'; -import { Spacing } from '@/constants/theme'; +import { Radius, Spacing } from '@/constants/theme'; import { formatPlan } from '@/core/format'; import { visibleMeters } from '@/core/meter-utils'; import { deriveStatus, type AccountView } from '@/data/summary'; @@ -11,7 +11,8 @@ import { renewalInfo } from '@/ui/meter-text'; import { MeterRow } from '@/ui/meter-row'; import { PressableScale } from '@/ui/motion'; import { ProviderIcon } from '@/ui/provider-icon'; -import { StatusBadge, statusLabel } from '@/ui/status-badge'; +import { CodexResetChip } from '@/ui/codex-reset-chip'; +import { StatusBadge } from '@/ui/status-badge'; export { forecastText, meterTitle, renewalInfo, resetText } from '@/ui/meter-text'; @@ -50,31 +51,36 @@ export function AccountCard({ scaleTo={0.985} > - {/* One column keeps names, plans and status readable with large fonts. */} + {/* One header line; it wraps instead of truncating when large fonts need the room. */} - - {name} + + + {name} + {subtitle ? ( - + {subtitle} ) : null} - - {/* Secondary metadata follows the account name. */} - {renewal || statusLabel(status, now, t) ? ( - - - {renewal ? ( - - {renewal.soon ? '▲ ' : ''} - {renewal.text} - + + {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} + - ) : null} + {meters.length > 0 ? ( @@ -84,6 +90,7 @@ export function AccountCard({ meter={m} now={now} view={view} + compact /> ))} @@ -104,15 +111,28 @@ export function AccountCard({ } const styles = StyleSheet.create({ - content: { gap: Spacing.three }, + content: { gap: Spacing.two }, header: { - alignItems: 'flex-start', - gap: Spacing.two, + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + columnGap: Spacing.two, + rowGap: Spacing.one, + }, + shrink: { flexShrink: 1 }, + trailing: { + marginLeft: 'auto', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'flex-end', + gap: Spacing.one, }, - meta: { - marginTop: -Spacing.two, - alignItems: 'flex-start', - gap: Spacing.two, + chip: { + borderWidth: 1, + borderRadius: Radius.pill, + paddingHorizontal: Spacing.two, + paddingVertical: 1, }, - meters: { gap: Spacing.two }, + meters: { gap: Spacing.one }, }); diff --git a/src/ui/codex-reset-chip.tsx b/src/ui/codex-reset-chip.tsx new file mode 100644 index 0000000..551d737 --- /dev/null +++ b/src/ui/codex-reset-chip.tsx @@ -0,0 +1,61 @@ +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 fcdb25e..69aac4e 100644 --- a/src/ui/codex-reset-section.tsx +++ b/src/ui/codex-reset-section.tsx @@ -1,10 +1,23 @@ import { useState } from 'react'; -import { Alert, Linking, View } from 'react-native'; +import { Alert, Linking, StyleSheet, View } from 'react-native'; import { Section } from '@/components/section'; import { ThemedText } from '@/components/themed-text'; -import { Spacing } from '@/constants/theme'; -import { confidenceText, errorText, eventText, localEventTime } from '@/data/codex-reset/format'; +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 { useCodexResetPreference } from '@/data/codex-reset/preferences'; import { @@ -14,11 +27,10 @@ import { type Incident, type ResetEvent, } from '@/data/codex-reset/models'; -import { isDataStale, SOURCE, type CachedData } from '@/data/codex-reset/service'; +import { isDataStale, SOURCE } from '@/data/codex-reset/service'; import { useTheme } from '@/hooks/use-theme'; import { useLocale, useT } from '@/i18n'; import { Button } from '@/ui/controls'; -import { ToggleRow } from '@/ui/toggles'; const open = (url: string) => void Linking.openURL(url).catch(() => {}); @@ -30,50 +42,11 @@ function SourceCredit() { ); } -function DataNote({ - entry, - updatedAt, - now, - upstreamStale = false, -}: { - entry?: CachedData; - updatedAt?: string | null; - now: Date; - upstreamStale?: boolean; -}) { - const t = useT(); - const locale = useLocale(); - return ( - - {entry?.data ? ( - <> - {isDataStale(entry, updatedAt, now.getTime(), upstreamStale) ? ( - - {t('Outdated data — showing the last available copy.')} - - ) : null} - - {t('Source updated {time}', { - time: localEventTime(updatedAt, locale, now), - })} - - - ) : null} - {entry?.error ? ( - - {errorText(entry.error, t)} - - ) : null} - - - ); -} - function EventRow({ event, now }: { event: ResetEvent; now: Date }) { const t = useT(); const locale = useLocale(); return ( - + {eventText(event, t)} {localEventTime(event.announced_at, locale, now)} @@ -96,7 +69,7 @@ function IncidentRow({ incident, now }: { incident: Incident; now: Date }) { 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')} @@ -117,8 +90,11 @@ function IncidentRow({ incident, now }: { incident: Incident; now: Date }) { ); } -/** Auxiliary public information sits below personal meters and never changes their status. */ -export function CodexResetSection({ now }: { now: Date }) { +/** + * 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. + */ +export function CodexResetSection({ now, weekly }: { now: Date; weekly?: Meter }) { const t = useT(); const preference = useCodexResetPreference(); const save = (enabled: boolean) => { @@ -147,24 +123,21 @@ export function CodexResetSection({ now }: { now: Date }) { ], ); }; - 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.", )} - value={preference.enabled} - disabled={!preference.loaded || preference.pending} - onChange={change} + +
- {preference.enabled ? : null} - + ); + } + return ( + change(false)} + /> ); } -function CodexResetContent({ now }: { now: Date }) { +function ResetRadar({ + 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 [moreEvents, setMoreEvents] = useState(false); - const [moreIncidents, setMoreIncidents] = useState(false); + const [more, setMore] = useState(false); const forecast = queries.forecast.data?.data; const timeline = queries.timeline.data?.data; const status = queries.status.data?.data; @@ -193,157 +182,390 @@ function CodexResetContent({ now }: { now: Date }) { const announcements = events.filter((e) => !isConfirmedReset(e)); const incidents = newestFirst(status?.incidents ?? [], (e) => e.started_at); const state = status ? codexState(status) : 'unknown'; - const unavailable = (loading: boolean) => ( - - {loading ? t('Loading…') : t('No public data available.')} - - ); + + 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; return ( - <> -
- {forecast ? ( - <> - {( - [ - ['24', forecast.probabilities.rounded_24h], - ['48', forecast.probabilities.rounded_48h], - ] as const - ).map(([hours, percent]) => ( - - - {t('Extra reset chance in {hours} hours', { hours })} - - - {percent == null ? t('Unknown') : `${percent}%`} - - - ))} +
+ + + {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.')} + + {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.')} - ) : null} - + open(SOURCE)} + > + {t('View announcement')} + + + ) : null} + + ) : ( + + {queries.forecast.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')} - - {localEventTime(forecast.last_reset_at, locale, now)} + + {`${formatAgo(lastReset, ms, t)} · ${localEventTime(forecast?.last_reset_at, locale, now)}`} - {forecast.official_signal ? ( - - {t('Official signal reported')} - - {t('A signal is not a confirmed reset.')} - - open(SOURCE)} - > - {t('View announcement')} - - - ) : null} - - ) : ( - unavailable(queries.forecast.isLoading) - )} - -
- -
- {timeline ? ( - <> - {confirmed.length ? ( - confirmed - .slice(0, moreEvents ? 10 : 2) - .map((event) => ) - ) : ( + ) : null} + {interval !== undefined ? ( + - {t('No confirmed resets reported.')} + {t('Average interval')} - )} - {moreEvents - ? announcements - .slice(0, 5) - .map((event) => ) - : null} - {confirmed.length > 2 || announcements.length > 0 ? ( -
+ + {t('about {n} days', { n: interval })} + + + ) : null} + + ) : null} -
- {status ? ( - <> - - {state === 'operational' - ? t('Codex operational') - : state === 'incident' - ? t('Codex service incident reported') - : t('Codex status unknown')} + {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; +}) { + 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); + 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')} + + )} + ); } + +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: { + 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/meter-row.tsx b/src/ui/meter-row.tsx index 747cb2c..26f2ce8 100644 --- a/src/ui/meter-row.tsx +++ b/src/ui/meter-row.tsx @@ -1,22 +1,43 @@ -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, useWindowDimensions, View } from 'react-native'; import { ThemedText } from '@/components/themed-text'; import { Spacing } from '@/constants/theme'; -import { level, usedFraction } from '@/core/meter-utils'; +import { elapsedFraction, level, usedFraction } from '@/core/meter-utils'; 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, resetText, useLevelColor } from '@/ui/meter-text'; +import { + forecastText, + meterTitle, + resetShort, + resetText, + useLevelColor, +} from '@/ui/meter-text'; import { windowFor, WindowBar } from '@/ui/window-bar'; -export function MeterRow({ meter, now, view }: { meter: Meter; now: Date; view?: AccountView }) { +/** Above this font scale a one-line row stops fitting; the compact row stacks instead. */ +const STACK_AT_FONT_SCALE = 1.3; + +export function MeterRow({ + meter, + now, + view, + compact = false, +}: { + meter: Meter; + now: Date; + view?: AccountView; + /** overview card: one line per meter (name, bar, used %, countdown) */ + compact?: boolean; +}) { const t = useT(); const theme = useTheme(); const forecast = view ? forecastText(view, meter, now, t) : undefined; const color = useLevelColor(level(usedFraction(meter))); const reset = resetText(meter, now, t); const window = windowFor(meter, t); + if (compact) return ; return ( {meterTitle(meter, t)} @@ -26,6 +47,7 @@ export function MeterRow({ meter, now, view }: { meter: Meter; now: Date; view?: used={window.used} remaining={window.remaining} label={window.label} + pace={elapsedFraction(meter, now)} /> {reset ? ( @@ -42,7 +64,113 @@ export function MeterRow({ meter, now, view }: { meter: Meter; now: Date; view?: ); } +/** + * Used share only: remaining is its complement and the details screen shows both. The thin + * tick on the bar is where an even pace would be by now. + */ +function CompactMeterRow({ + meter, + now, + forecast, + color, +}: { + meter: Meter; + now: Date; + forecast?: string; + color: string; +}) { + 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); + const value = + fraction === undefined ? window.used : `${Math.round(Math.max(0, fraction) * 100)}%`; + const reset = resetText(meter, now, t); + const short = resetShort(meter, now, t); + const valueColor = fraction !== undefined && fraction >= 0.6 ? color : theme.text; + const label = [ + title, + fraction === undefined ? `${window.label ?? t('used')} ${window.used}` : `${t('used')} ${value}`, + reset, + forecast, + ] + .filter(Boolean) + .join(', '); + + const bar = + fraction === undefined ? null : ( + + ); + + return ( + + {stacked ? ( + + + + {title} + + + {value} + + + {bar} + {reset ? ( + + {reset} + + ) : null} + + ) : ( + + + {title} + + {bar ? {bar} : null} + + {value} + + + {short ?? ''} + + + )} + {forecast ? ( + + {'▲ '} + {forecast} + + ) : null} + + ); +} + const styles = StyleSheet.create({ row: { gap: Spacing.one, paddingVertical: Spacing.one }, number: { fontVariant: ['tabular-nums'] }, + compact: { gap: Spacing.half }, + stack: { gap: Spacing.one }, + line: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, minHeight: 24 }, + flex: { flex: 1 }, + title: { width: 76 }, + 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 b324d8f..8384d7a 100644 --- a/src/ui/meter-text.ts +++ b/src/ui/meter-text.ts @@ -16,6 +16,13 @@ export function resetText(m: Meter, now: Date, t: Translator): string | undefine return c.elapsed ? t('Reset due') : t('Resets in {time}', { time: formatCountdown(c.label, t) }); } +/** Countdown without spaces ("2d5h") for the one-line row; the full sentence stays for screen readers. */ +export function resetShort(m: Meter, now: Date, t: Translator): string | undefined { + const c = countdown(m.resetsAt, now); + if (!c) return undefined; + return c.elapsed ? t('Due') : formatCountdown(c.label, t).replace(/\s+/g, ''); +} + const keyOf = (m: Meter) => `${m.id}${m.scope.type === 'model' ? `:${m.scope.name}` : ''}`; /** "Runs out in 1h 20m at this pace" when the current pace empties the window before it resets. */ @@ -37,12 +44,16 @@ export function renewalInfo( view: AccountView, now: Date, t: Translator, -): { text: string; soon: boolean } | undefined { +): { text: string; left: 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 }), soon: days <= 7 }; + return { + text: t('Plan until {date} · {left}', { date: formatDate(at), left }), + left: t('Plan ends · {left}', { left }), + soon: days <= 7, + }; } /** Level colour for a usage share; always paired with the printed figures. */ diff --git a/src/ui/window-bar.tsx b/src/ui/window-bar.tsx index e3cbbd1..d4e5079 100644 --- a/src/ui/window-bar.tsx +++ b/src/ui/window-bar.tsx @@ -46,6 +46,8 @@ export function WindowBar({ used, remaining, label, + pace, + bare = false, }: { /** used share of the window, 0..1; undefined when there is no split (balance meters) */ fraction?: number; @@ -54,6 +56,10 @@ export function WindowBar({ remaining?: string; /** amount label, defaults to "used"; balance meters pass the balance label */ label?: string; + /** share of the window's time already gone, 0..1: drawn as a tick where an even pace would be */ + pace?: number; + /** the bar alone, for rows that print the figures themselves */ + bare?: boolean; }) { const theme = useTheme(); const t = useT(); @@ -74,18 +80,31 @@ export function WindowBar({ ); } + const track = ( + + + {pace === undefined ? null : ( + + )} + + ); + if (bare) return track; + return ( {text} {used} - - - + {track} {remaining === undefined ? null : ( {`${t('Remaining')} ${remaining}`} @@ -104,5 +123,8 @@ const styles = StyleSheet.create({ borderRadius: Radius.pill, overflow: 'hidden', }, + thin: { height: 6 }, fill: { height: '100%', borderRadius: Radius.pill }, + // translucent so it reads on both the empty track and the coloured fill + pace: { position: 'absolute', top: 0, bottom: 0, width: 2, marginLeft: -1, opacity: 0.45 }, });