diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts index db2efd5da17b..78c2d30ccdab 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts @@ -35,8 +35,14 @@ const provider = { name: "Codex", detail: "Subscription remaining", windows: [ - { kind: "session", label: "5 hours", remaining: 60, reset: "Next reset Sep 5, 5:00 PM" }, - { kind: "weekly", label: "Weekly", remaining: 8, reset: "Next reset Sep 9, 9:00 AM" }, + { + kind: "session", + label: "5 hours", + remaining: 60, + resetsAt: "2026-09-05T14:13:00.000Z", + reset: "resets in 2h 14m", + }, + { kind: "weekly", label: "Weekly", remaining: 8, reset: "Reset time unavailable" }, ], expiresAt: now + 60_000, totalWindows: 2, @@ -57,7 +63,10 @@ describe("SubscriptionUsage Android layout", () => { const tree = render(snapshot); expect(tree).toContain("5 hours ยท 60% left"); expect(tree).toContain('"progress":0.6'); - expect(tree).toContain("Next reset Sep 5, 5:00 PM"); + // The countdown is recomputed at render time; the stored text is only a fallback. + expect(tree).toContain("resets in 2h 13m"); + expect(tree).not.toContain("resets in 2h 14m"); + expect(tree).toContain("Reset time unavailable"); expect(tree).toContain('"progress":0.08'); expect(tree).toContain('"color":"dark-error"'); expect(tree).toContain("As of "); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx index ac47edf8cdd1..59263c883781 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx @@ -19,8 +19,8 @@ import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subs export function SubscriptionUsage(props: SubscriptionUsageProps, environment: WidgetEnvironment) { "widget"; // The widget runtime evaluates this function without the app's module scope. - // Android has no timeline, so freshness is decided on every render; the - // expiry alarm and each tap trigger one while the app is closed. + // Android has no timeline, so freshness and countdowns are decided on every + // render; the refresh alarms and each tap trigger one while the app is closed. const now = Date.now(); // The 4x3 default cell fits two quotas per provider with their reset text. const limit = 2; @@ -65,6 +65,17 @@ export function SubscriptionUsage(props: SubscriptionUsageProps, environment: Wi ) : null} {shown.map((window) => { const low = window.remaining <= 10; + // Mirrors formatResetsIn in @t3tools/shared/usageLimits, which is + // out of reach here; snapshots stored without resetsAt keep their text. + const left = Date.parse(window.resetsAt ?? "") - now; + const minutes = Math.floor(left / 60_000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const reset = !Number.isFinite(left) + ? window.reset + : left <= 0 + ? "resets now" + : `resets in ${days > 0 ? `${days}d ${hours % 24}h` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`}`; return ( - {window.reset} + {reset} ); diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts index 406772d3d530..745e67fdf5ed 100644 --- a/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts @@ -1,6 +1,9 @@ import { requireOptionalNativeModule } from "expo"; import * as Linking from "expo-linking"; -import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; +import { + subscriptionUsageTimeline, + type SubscriptionUsageSnapshot, +} from "./subscriptionUsageSnapshot"; let tapUrl: string | undefined; let listening = false; @@ -23,11 +26,12 @@ export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapsh } widget.updateSnapshot(snapshot); // Android has no timeline; an alarm re-renders the stored snapshot at each - // deadline so stale readings flip to "Open T3 to refresh" unattended. + // timeline date so countdowns advance and stale readings flip to + // "Open T3 to refresh" unattended. requireOptionalNativeModule<{ schedule: (name: string, deadlines: number[]) => void }>( "T3WidgetExpiry", )?.schedule( "SubscriptionUsage", - snapshot.providers.map((provider) => provider.expiresAt).filter((at) => at > 0), + subscriptionUsageTimeline(snapshot, Date.now()).map((entry) => entry.date.getTime()), ); } diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 8ab3d6191ef5..bd5f683a8200 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -221,13 +221,51 @@ describe("subscription widget snapshots", () => { expect(snapshot.providers[0]?.expiresAt).toBe(now + 15 * 60_000); expect(snapshot.providers[0]?.windows[0]?.reset).toBe("Reset time unavailable"); }); + it.each([ + ["2026-09-05T14:13:00.000Z", "resets in 2h 13m"], + ["2026-09-08T16:00:00.000Z", "resets in 3d 4h"], + [undefined, "Reset time unavailable"], + ])("formats reset %s relative to the timeline entry", (resetsAt, expected) => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt }] } })]), + deepLink, + ); + expect(snapshot.providers[0]?.windows[0]?.reset).toBe(expected); + expect(subscriptionUsageTimeline(snapshot, now)[0]?.props.providers[0]?.windows[0]?.reset).toBe( + expected, + ); + }); it("schedules a reset boundary without inventing a zero quota", () => { const snapshot = buildSubscriptionUsageSnapshot(presentations(), deepLink); const timeline = subscriptionUsageTimeline(snapshot, now); - expect(timeline.map((entry) => entry.date.getTime())).toEqual([now, now + 10 * 60_000]); - expect(timeline[1]?.props.providers[0]?.windows).toEqual([]); + expect(timeline.map((entry) => entry.date.getTime())).toEqual( + Array.from({ length: 11 }, (_, minute) => now + minute * 60_000), + ); + expect(timeline[0]?.props.providers[0]?.windows[0]?.reset).toBe("resets in 10m"); + expect(timeline[1]?.props.providers[0]?.windows[0]?.reset).toBe("resets in 9m"); + expect(timeline.at(-1)?.props.providers[0]?.windows).toEqual([]); expect(subscriptionUsageTimeline(snapshot, now + 60 * 60_000)).toHaveLength(1); }); + it("includes the countdown cap when the server clock is ahead", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ + usageLimits: { + checkedAt: new Date(now + 5 * 60_000).toISOString(), + windows: [{ ...window, resetsAt: new Date(now + 120 * 60_000).toISOString() }], + }, + }), + ]), + deepLink, + ); + const timeline = subscriptionUsageTimeline(snapshot, now); + expect(timeline.map((entry) => entry.date.getTime())).toEqual([ + ...Array.from({ length: 16 }, (_, minute) => now + minute * 60_000), + now + 20 * 60_000, + ]); + expect(timeline[15]?.props.providers[0]?.windows[0]?.reset).toBe("resets in 1h 45m"); + expect(timeline.at(-1)?.props.providers[0]?.windows).toEqual([]); + }); it("expires providers independently without inventing a refill", () => { const snapshot = buildSubscriptionUsageSnapshot( presentations([ @@ -241,14 +279,12 @@ describe("subscription widget snapshots", () => { deepLink, ); const timeline = subscriptionUsageTimeline(snapshot, now); - expect(timeline.map((entry) => entry.date.getTime())).toEqual([ - now, - now + 10 * 60_000, - now + 15 * 60_000, - ]); - expect(timeline[1]?.props.providers[0]?.windows).toEqual([]); - expect(timeline[1]?.props.providers[1]?.windows[0]?.remaining).toBe(60); - expect(timeline[2]?.props.providers.every((provider) => provider.windows.length === 0)).toBe( + expect(timeline.map((entry) => entry.date.getTime())).toEqual( + Array.from({ length: 16 }, (_, minute) => now + minute * 60_000), + ); + expect(timeline[10]?.props.providers[0]?.windows).toEqual([]); + expect(timeline[10]?.props.providers[1]?.windows[0]?.remaining).toBe(60); + expect(timeline[15]?.props.providers.every((provider) => provider.windows.length === 0)).toBe( true, ); }); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 44f2c6ef4bed..af1356d80167 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -1,6 +1,7 @@ import { collectLimitAccounts, collectLimitPools, + formatResetsIn, type LimitAccount, type LimitPresentations, } from "@t3tools/shared/usageLimits"; @@ -11,7 +12,13 @@ export interface SubscriptionUsageSnapshot { providers: Array<{ name: string; detail: string; - windows: Array<{ kind?: string; label: string; remaining: number; reset: string }>; + windows: Array<{ + kind?: string; + label: string; + remaining: number; + resetsAt?: string; + reset: string; + }>; expiresAt: number; totalWindows: number; }>; @@ -105,13 +112,10 @@ function subscriptionUsageProps( kind: window.kind, label: window.label, remaining: Math.round(window.remainingPercent), + resetsAt: window.resets[0]?.member.window.resetsAt, reset: window.resets[0] - ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })}` + ? (formatResetsIn(window.resets[0].member.window, checkedAt) ?? + "Reset time unavailable") : "Reset time unavailable", })) : [], @@ -155,15 +159,27 @@ export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, n const deadlines = [...new Set(snapshot.providers.map((p) => p.expiresAt))] .filter((deadline) => deadline > now) .sort((a, b) => a - b); - return [now, ...deadlines].map((date) => ({ - date: new Date(date), - props: { - ...snapshot, - providers: snapshot.providers.map((provider) => - provider.windows.length > 0 && provider.expiresAt <= date - ? { ...provider, detail: "Open T3 to refresh", windows: [], totalWindows: 0 } - : provider, - ), - }, - })); + // Countdown entries are bounded by snapshot expiry (at most fifteen minutes). + const dates = new Set([now, ...deadlines]); + const lastDeadline = Math.min(deadlines.at(-1) ?? now, now + SNAPSHOT_MAX_AGE); + for (let date = now + 60_000; date <= lastDeadline; date += 60_000) dates.add(date); + return [...dates] + .sort((a, b) => a - b) + .map((date) => ({ + date: new Date(date), + props: { + ...snapshot, + providers: snapshot.providers.map((provider) => + provider.windows.length > 0 && provider.expiresAt <= date + ? { ...provider, detail: "Open T3 to refresh", windows: [], totalWindows: 0 } + : { + ...provider, + windows: provider.windows.map((window) => ({ + ...window, + reset: formatResetsIn(window, date) ?? "Reset time unavailable", + })), + }, + ), + }, + })); } diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b1211bffd54d..49ef7cbbd759 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -490,7 +490,7 @@ export function remainingPercent(window: ServerProviderUsageWindow): number { return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); } -function resetMillis(window: ServerProviderUsageWindow): number | null { +function resetMillis(window: Pick): number | null { if (window.resetsAt === undefined) return null; const at = Date.parse(window.resetsAt); return Number.isFinite(at) ? at : null; @@ -536,7 +536,10 @@ export function formatDuration(ms: number): string { } /** `resets in 2h 13m`, or null when the window has no reset. */ -export function formatResetsIn(window: ServerProviderUsageWindow, now: number): string | null { +export function formatResetsIn( + window: Pick, + now: number, +): string | null { const resetsAt = resetMillis(window); if (resetsAt === null) return null; return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`;