Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/mobile/src/widgets/SubscriptionUsage.android.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ");
Expand Down
17 changes: 14 additions & 3 deletions apps/mobile/src/widgets/SubscriptionUsage.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<Column key={window.label} modifiers={[fillMaxWidth(), padding(0, 4, 0, 0)]}>
<Text
Expand All @@ -83,7 +94,7 @@ export function SubscriptionUsage(props: SubscriptionUsageProps, environment: Wi
/>
</Column>
<Text color={muted} maxLines={1} style={{ fontSize: 10 }}>
{window.reset}
{reset}
</Text>
</Column>
);
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/widgets/publishSubscriptionUsage.android.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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()),
);
}
56 changes: 46 additions & 10 deletions apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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,
);
});
Expand Down
52 changes: 34 additions & 18 deletions apps/mobile/src/widgets/subscriptionUsageSnapshot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
collectLimitAccounts,
collectLimitPools,
formatResetsIn,
type LimitAccount,
type LimitPresentations,
} from "@t3tools/shared/usageLimits";
Expand All @@ -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;
}>;
Expand Down Expand Up @@ -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",
}))
: [],
Expand Down Expand Up @@ -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",
})),
},
),
},
}));
}
7 changes: 5 additions & 2 deletions packages/shared/src/usageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerProviderUsageWindow, "resetsAt">): number | null {
if (window.resetsAt === undefined) return null;
const at = Date.parse(window.resetsAt);
return Number.isFinite(at) ? at : null;
Expand Down Expand Up @@ -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<ServerProviderUsageWindow, "resetsAt">,
now: number,
): string | null {
const resetsAt = resetMillis(window);
if (resetsAt === null) return null;
return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`;
Expand Down
Loading