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)}`;