diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index f34f9c4b39d8..15f3cb5e3148 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -130,6 +130,12 @@ const widgetsPlugin: NonNullable[number] = [ // frequent-updates entitlement iOS throttles the update budget sooner. frequentUpdates: true, widgets: [ + { + name: "SubscriptionUsage", + displayName: "Subscription usage", + description: "Subscription quotas from your connected T3 Code environments.", + supportedFamilies: ["systemSmall", "systemMedium", "systemLarge"], + }, { name: "AgentActivity", displayName: "Agent Activity", diff --git a/apps/mobile/modules/t3-subscription-widget/android/build.gradle b/apps/mobile/modules/t3-subscription-widget/android/build.gradle new file mode 100644 index 000000000000..7de5052417c0 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.subscriptionwidget' +version = '0.0.0' + +android { + namespace 'expo.modules.t3subscriptionwidget' + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..e66f03cffae5 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt new file mode 100644 index 000000000000..8cd69b187b86 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt @@ -0,0 +1,124 @@ +package expo.modules.t3subscriptionwidget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.RemoteViews +import org.json.JSONObject +import java.text.DateFormat +import java.util.Date + +class SubscriptionUsageWidget : AppWidgetProvider() { + override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { + ids.forEach { update(context, manager, it) } + } + + override fun onAppWidgetOptionsChanged( + context: Context, + manager: AppWidgetManager, + id: Int, + options: Bundle + ) { + update(context, manager, id) + } + + companion object { + const val PREFERENCES = "t3_subscription_widget" + + fun updateAll(context: Context) { + val manager = AppWidgetManager.getInstance(context) + manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java)) + .forEach { update(context, manager, it) } + } + + private fun update(context: Context, manager: AppWidgetManager, id: Int) { + val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) + val snapshot = try { + saved?.let { JSONObject(it) } + } catch (_: Exception) { + null + } + val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) + openAppIntent(context, id, snapshot)?.let { + views.setOnClickPendingIntent(R.id.t3_widget_root, it) + } + val rows = snapshot?.optJSONArray("rows") + if (rows != null && rows.length() > 0) { + views.removeAllViews(R.id.t3_widget_rows) + val options = manager.getAppWidgetOptions(id) + val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) + val count = ((height - 64) / 66).coerceIn(1, 8).coerceAtMost(rows.length()) + var oldest = Long.MAX_VALUE + for (index in 0 until count) { + val row = rows.optJSONObject(index) ?: continue + val child = rowView(context, row) + oldest = minOf(oldest, row.optLong("checkedAt")) + views.addView(R.id.t3_widget_rows, child) + } + val remaining = (snapshot?.optInt("totalRows", rows.length()) ?: rows.length()) - count + val formatted = DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT + ).format(Date(oldest)) + val more = if (remaining > 0) { + context.getString(R.string.t3_subscription_widget_more, remaining) + } else { + "" + } + views.setTextViewText( + R.id.t3_widget_footer, + ( + if (oldest > 0) { + context.getString(R.string.t3_subscription_widget_as_of, formatted) + } else { + context.getString(R.string.t3_subscription_widget_unknown_check) + } + ) + more + ) + } + manager.updateAppWidget(id, views) + } + + private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? { + // Target this variant's launcher so co-installed builds cannot steal the tap. + val intent = + context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + intent.action = Intent.ACTION_VIEW + val deepLink = snapshot?.optString("deepLink")?.takeIf { it.isNotBlank() } + ?: "t3code://settings/usage?tab=limits" + intent.data = Uri.parse(deepLink) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + return PendingIntent.getActivity( + context, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun rowView(context: Context, row: JSONObject): RemoteViews { + val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) + val used = if (row.isNull("usedPercent")) null else row.optInt("usedPercent").coerceIn(0, 100) + child.setTextViewText(R.id.t3_widget_label, row.optString("label")) + child.setTextViewText(R.id.t3_widget_window, row.optString("window")) + val percent = used?.let { context.getString(R.string.t3_subscription_widget_used, it) } ?: "—" + child.setTextViewText(R.id.t3_widget_percent, percent) + val visibility = if (used == null) View.GONE else View.VISIBLE + child.setViewVisibility(R.id.t3_widget_progress, visibility) + if (used != null) child.setProgressBar(R.id.t3_widget_progress, 100, used, false) + val reset = if (row.optLong("expiresAt") <= System.currentTimeMillis()) { + context.getString(R.string.t3_subscription_widget_refresh) + } else { + row.optString("resetLabel") + } + child.setTextViewText(R.id.t3_widget_reset, reset) + return child + } + } +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt new file mode 100644 index 000000000000..6557b70958de --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt @@ -0,0 +1,18 @@ +package expo.modules.t3subscriptionwidget + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import org.json.JSONObject + +class T3SubscriptionWidgetModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3SubscriptionWidget") + Function("updateSnapshot") { snapshot: String -> + val context = appContext.reactContext ?: return@Function + JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot. + context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0) + .edit().putString("snapshot", snapshot).apply() + SubscriptionUsageWidget.updateAll(context) + } + } +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml new file mode 100644 index 000000000000..69ec028ee8af --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml new file mode 100644 index 000000000000..58e55bc2c81e --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml new file mode 100644 index 000000000000..7c98f66ec137 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml new file mode 100644 index 000000000000..0647eb1a8ea7 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + #18181B + #FAFAFA + #A1A1AA + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml new file mode 100644 index 000000000000..bf4ca10ecfc4 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #FAFAFA + #18181B + #52525B + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml new file mode 100644 index 000000000000..d41800f6c507 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + Last checked unavailable + Subscription usage + Saved subscription quotas from your T3 Code environments. Tap to refresh in the app. + Open T3 Code and connect an environment to see limits. + Tap to open Usage + Open app to refresh + %1$d%% used + As of %1$s + · +%1$d more + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml new file mode 100644 index 000000000000..4dfdad5b0fec --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml @@ -0,0 +1,9 @@ + diff --git a/apps/mobile/modules/t3-subscription-widget/expo-module.config.json b/apps/mobile/modules/t3-subscription-widget/expo-module.config.json new file mode 100644 index 000000000000..7ddc9c09e221 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["android"], + "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] } +} diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index b7f1db54e8c0..852b6c0560e3 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -24,6 +24,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; +import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; + import "../global.css"; if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { @@ -77,6 +79,7 @@ function AppContent() { return ( <> + diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index ad54d6e323ee..2e571b114775 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,5 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { useNavigation } from "@react-navigation/native"; +import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -65,9 +65,21 @@ const CHART_HEIGHT = 180; * pull to refresh, each refreshing its own data. */ export function UsageRouteScreen() { + const route = useRoute>(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const [tab, setTab] = useState("usage"); + const [selection, setSelection] = useState(() => ({ + params: route.params, + tab: (route.params?.tab === "limits" ? "limits" : "usage") as UsageTab, + })); + if (selection.params !== route.params) { + setSelection({ + params: route.params, + tab: route.params?.tab === "limits" ? "limits" : "usage", + }); + } + const { tab } = selection; + const setTab = (tab: UsageTab) => setSelection({ params: route.params, tab }); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), diff --git a/apps/mobile/src/widgets/SubscriptionUsage.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.test.ts new file mode 100644 index 000000000000..14952bc787b0 --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsage.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +vi.mock("@expo/ui/swift-ui", () => + Object.fromEntries( + ["HStack", "ProgressView", "Spacer", "Text", "VStack"].map((name) => [name, name]), + ), +); +vi.mock("@expo/ui/swift-ui/modifiers", () => + Object.fromEntries( + ["font", "foregroundStyle", "lineLimit", "tint", "widgetURL"].map((name) => [ + name, + (value: unknown) => ({ [name]: value }), + ]), + ), +); +vi.mock("expo-widgets", () => ({ createWidget: (name: string) => ({ name }) })); +import { SubscriptionUsage } from "./SubscriptionUsage"; +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +const now = Date.parse("2026-09-05T12:00:00Z"); +const snapshot: SubscriptionUsageSnapshot = { + deepLink: "t3code-dev://settings/usage?tab=limits", + totalRows: 8, + rows: Array.from({ length: 8 }, (_, index) => ({ + label: `Provider ${index}`, + window: "Weekly", + usedPercent: 90, + resetLabel: "Resets tomorrow", + checkedAt: now, + expiresAt: now + 600_000, + })), +}; + +describe("iOS subscription widget", () => { + it.each([ + ["systemSmall", 1], + ["systemMedium", 2], + ["systemLarge", 4], + ] as const)("bounds %s content and reports omitted windows", (widgetFamily, count) => { + const tree = JSON.stringify( + SubscriptionUsage(snapshot, { date: new Date(now), widgetFamily, configuration: undefined }), + ); + expect(tree).toContain(`Provider ${count - 1}`); + expect(tree).not.toContain(`Provider ${count}`); + expect(tree).toContain(`+${8 - count} more`); + expect(tree).toContain(snapshot.deepLink); + }); + it("renders gallery props without account data", () => { + const tree = JSON.stringify( + SubscriptionUsage({} as SubscriptionUsageSnapshot, { + date: new Date(now), + widgetFamily: "systemSmall", + configuration: undefined, + }), + ); + expect(tree).toContain("connect an environment"); + expect(tree).not.toContain("Invalid Date"); + }); + it("replaces reset copy when the snapshot expires", () => { + const tree = JSON.stringify( + SubscriptionUsage(snapshot, { + date: new Date(now + 600_000), + widgetFamily: "systemSmall", + configuration: undefined, + }), + ); + expect(tree).toContain("Open app to refresh"); + expect(tree).not.toContain("Resets tomorrow"); + expect(tree).toContain("90% used"); + }); + it("renders an omitted quota as unavailable rather than zero", () => { + const tree = JSON.stringify( + SubscriptionUsage( + { + ...snapshot, + rows: [ + { + label: "Claude", + window: "No subscription limits", + resetLabel: "Open app for details", + checkedAt: 0, + expiresAt: 0, + }, + ], + totalRows: 1, + }, + { date: new Date(now), widgetFamily: "systemSmall", configuration: undefined }, + ), + ); + expect(tree).toContain("—"); + expect(tree).toContain("No subscription limits"); + expect(tree).toContain("Last checked unavailable"); + expect(tree).not.toContain("1970"); + expect(tree).not.toContain("ProgressView"); + expect(tree).not.toContain("0% used"); + }); +}); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx new file mode 100644 index 000000000000..00eca6d71a0a --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -0,0 +1,95 @@ +import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { font, foregroundStyle, lineLimit, tint, widgetURL } from "@expo/ui/swift-ui/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +export function SubscriptionUsage( + props: SubscriptionUsageSnapshot, + environment: WidgetEnvironment, +) { + "widget"; + const rows = props.rows ?? []; + const count = + environment.widgetFamily === "systemLarge" + ? 4 + : environment.widgetFamily === "systemMedium" + ? 2 + : 1; + const visible = rows.slice(0, count); + const oldest = Math.min(...visible.map((row) => row.checkedAt)); + const now = environment.date.getTime(); + const renderRow = (row: SubscriptionUsageSnapshot["rows"][number], index: number) => ( + + + + {row.label} + + + + {typeof row.usedPercent !== "number" ? "—" : `${row.usedPercent}% used`} + + + + {row.window} + + {typeof row.usedPercent === "number" ? ( + = 90 ? "#dc2626" : row.usedPercent >= 70 ? "#d97706" : "#0284c7"), + ]} + /> + ) : null} + + {row.expiresAt <= now ? "Open app to refresh" : row.resetLabel} + + + ); + + return ( + + + {environment.widgetFamily === "systemSmall" ? "Usage limits" : "Subscription usage"} + + {visible.length === 0 ? ( + + Open T3 Code and connect an environment to see limits. + + ) : null} + {environment.widgetFamily === "systemMedium" ? ( + + {visible.map(renderRow)} + + ) : ( + visible.map(renderRow) + )} + + + {visible.length > 0 + ? oldest > 0 + ? `As of ${new Date(oldest).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` + : "Last checked unavailable" + : "Tap to open Usage"} + + {props.totalRows > visible.length ? ( + + {`+${props.totalRows - visible.length} more`} + + ) : null} + + ); +} + +export default createWidget("SubscriptionUsage", SubscriptionUsage); diff --git a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx new file mode 100644 index 000000000000..3d4ad4ab6c4a --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx @@ -0,0 +1,30 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import * as Linking from "expo-linking"; +import { useEffect } from "react"; +import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations } from "../state/presentation"; +import { publishSubscriptionUsage } from "./publishSubscriptionUsage"; +import { buildSubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +// Isolate quota changes from the much busier thread/config presentation stream. +const snapshotAtom = Atom.make((get) => + buildSubscriptionUsageSnapshot( + get(environmentPresentations.presentationsAtom), + Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }), + ), +).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); + +export function SubscriptionUsageCoordinator() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const snapshot = useAtomValue(snapshotAtom); + useEffect(() => { + if (!catalog.isReady) return; + void Promise.resolve() + .then(() => publishSubscriptionUsage(snapshot)) + .catch((error: unknown) => { + console.warn("Could not update subscription usage widget", error); + }); + }, [catalog.isReady, snapshot]); + return null; +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts new file mode 100644 index 000000000000..a37b4f61eca3 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts @@ -0,0 +1,8 @@ +import { requireOptionalNativeModule } from "expo"; +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { + requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>( + "T3SubscriptionWidget", + )?.updateSnapshot(JSON.stringify(snapshot)); +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts new file mode 100644 index 000000000000..c386f927f942 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts @@ -0,0 +1,11 @@ +import { requireOptionalNativeModule } from "expo"; +import { + subscriptionUsageTimeline, + type SubscriptionUsageSnapshot, +} from "./subscriptionUsageSnapshot"; + +export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./SubscriptionUsage"); + widget.updateTimeline(subscriptionUsageTimeline(snapshot, Date.now())); +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.ts new file mode 100644 index 000000000000..3a50eb20d820 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.ts @@ -0,0 +1,3 @@ +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +export function publishSubscriptionUsage(_snapshot: SubscriptionUsageSnapshot) {} diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts new file mode 100644 index 000000000000..43f117797b43 --- /dev/null +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -0,0 +1,175 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + UsageLimitSourceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + buildSubscriptionUsageSnapshot, + subscriptionUsageTimeline, +} from "./subscriptionUsageSnapshot"; + +const checkedAt = "2026-09-05T12:00:00.000Z"; +const now = Date.parse(checkedAt); +const window = { + id: "session", + kind: "session", + label: "5 hours", + usedPercent: 40, + resetsAt: "2026-09-05T12:10:00.000Z", +} as const; +const limits = { checkedAt, windows: [window] }; +const deepLink = "t3code-dev://settings/usage?tab=limits"; +function provider(overrides: Partial = {}): ServerProvider { + return { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", email: "private@example.com" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits: limits, + ...overrides, + }; +} +function presentations(providers: readonly ServerProvider[] = [provider()]) { + return new Map([ + [ + EnvironmentId.make("env"), + { entry: { target: { label: "Remote" } }, serverConfig: { providers } }, + ], + ]); +} + +describe("subscription widget snapshots", () => { + it("uses provider data and its observation time without exposing account emails", () => { + const snapshot = buildSubscriptionUsageSnapshot(presentations(), deepLink); + expect(snapshot.rows[0]).toMatchObject({ + label: "Codex", + usedPercent: 40, + checkedAt: now, + expiresAt: now + 10 * 60_000, + }); + expect(snapshot.deepLink).toBe(deepLink); + expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); + }); + it("never shows an email-bearing instance name on the home screen", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ displayName: "work@example.com" })]), + deepLink, + ); + expect(snapshot.rows[0]?.label).toBe("Codex"); + expect(JSON.stringify(snapshot)).not.toContain("example.com"); + }); + it("clears data after removing environments and hides disabled providers", () => { + expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).rows).toEqual([]); + expect( + buildSubscriptionUsageSnapshot(presentations([provider({ enabled: false })]), deepLink).rows, + ).toEqual([]); + }); + it("uses upstream deduplication for a native account also present in a proxy hub", () => { + const input = new Map([ + [ + EnvironmentId.make("env"), + { + entry: { target: { label: "Remote" } }, + serverConfig: { + providers: [provider()], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Hub", + checkedAt, + accounts: [ + { + id: "account", + driver: ProviderDriverKind.make("codex"), + email: " PRIVATE@example.com ", + usageLimits: limits, + }, + ], + }, + ], + }, + }, + ], + ]); + expect(buildSubscriptionUsageSnapshot(input, deepLink).rows).toHaveLength(1); + input.get(EnvironmentId.make("env"))!.serverConfig.providers = []; + const snapshot = buildSubscriptionUsageSnapshot(input, deepLink); + expect(snapshot.rows[0]?.label).toBe("Hub · Codex 1"); + expect(JSON.stringify(snapshot)).not.toContain("example.com"); + }); + it("keeps unavailable quotas distinct from zero usage and omits provider error messages", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ + usageLimits: { + ...limits, + unavailable: { reason: "probeFailed", message: "token secret" }, + }, + }), + ]), + deepLink, + ); + expect(snapshot.rows[0]).toMatchObject({ window: "Limits unavailable" }); + expect(snapshot.rows[0]).not.toHaveProperty("usedPercent"); + expect(JSON.stringify(snapshot)).not.toContain("token secret"); + expect( + buildSubscriptionUsageSnapshot( + presentations([ + provider({ usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 0 }] } }), + ]), + deepLink, + ).rows[0]?.usedPercent, + ).toBe(0); + }); + it("bounds OS storage and puts the most constrained windows first", () => { + const windows = Array.from({ length: 20 }, (_, index) => ({ + ...window, + id: `${index}`, + usedPercent: index * 5, + })); + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows } })]), + deepLink, + ); + expect(snapshot.rows).toHaveLength(8); + expect(snapshot.totalRows).toBe(20); + expect(snapshot.rows[0]?.usedPercent).toBe(95); + }); + it("marks unknown or distant reset times stale after thirty minutes", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] } }), + ]), + deepLink, + ); + expect(snapshot.rows[0]?.expiresAt).toBe(now + 30 * 60_000); + expect(snapshot.rows[0]?.resetLabel).toBe("Reset time unavailable"); + }); + 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.rows[0]?.usedPercent).toBe(40); + expect(subscriptionUsageTimeline(snapshot, now + 60 * 60_000)).toHaveLength(1); + }); + it("marks a malformed check time immediately stale without changing the quota", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { ...limits, checkedAt: "invalid" } })]), + deepLink, + ); + expect(snapshot.rows[0]).toMatchObject({ checkedAt: 0, expiresAt: 0, usedPercent: 40 }); + expect(subscriptionUsageTimeline(snapshot, now)).toHaveLength(1); + expect(JSON.stringify(snapshot)).not.toContain("null"); + }); +}); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts new file mode 100644 index 000000000000..38425791ade8 --- /dev/null +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -0,0 +1,108 @@ +import type { ServerProvider, ServerProviderUsageLimits } from "@t3tools/contracts"; +import { collectLimitSources, collectLimitsGroups } from "@t3tools/shared/usageLimits"; + +export interface SubscriptionUsageRow { + readonly label: string; + readonly window: string; + // Omit unavailable values: iOS widget storage accepts property lists, not null. + readonly usedPercent?: number; + readonly resetLabel: string; + readonly expiresAt: number; + readonly checkedAt: number; +} + +export interface SubscriptionUsageSnapshot { + readonly rows: readonly SubscriptionUsageRow[]; + readonly totalRows: number; + readonly deepLink: string; +} + +type Presentations = Parameters[0] & + Parameters[0]; +const MAX_AGE = 30 * 60_000; + +/** Only display data crosses into OS storage; credentials and emails stay in the app. */ +export function buildSubscriptionUsageSnapshot( + presentations: Presentations, + deepLink: string, +): SubscriptionUsageSnapshot { + const rows: SubscriptionUsageRow[] = []; + const add = (label: string, limits: ServerProviderUsageLimits, sourceFailed = false) => { + const parsedCheckedAt = Date.parse(limits.checkedAt); + const checkedAt = Number.isFinite(parsedCheckedAt) ? parsedCheckedAt : 0; + if (limits.unavailable || sourceFailed || limits.windows.length === 0) { + rows.push({ + label, + window: + limits.unavailable?.reason === "unsupported" + ? "No subscription limits" + : "Limits unavailable", + resetLabel: "Open app for details", + checkedAt, + expiresAt: 0, + }); + return; + } + for (const window of limits.windows) { + const reset = window.resetsAt ? Date.parse(window.resetsAt) : NaN; + rows.push({ + label, + window: window.label, + usedPercent: Math.round(Math.max(0, Math.min(100, window.usedPercent))), + resetLabel: Number.isFinite(reset) + ? `Resets ${new Date(reset).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` + : "Reset time unavailable", + checkedAt, + expiresAt: + checkedAt === 0 + ? 0 + : Math.min(checkedAt + MAX_AGE, Number.isFinite(reset) ? reset : Infinity), + }); + } + }; + const driverLabel = (driver: string) => ({ codex: "Codex", claudeAgent: "Claude" })[driver]; + // Home screens have no reveal control, so email-bearing names fall back to the driver. + const providerLimitsLabel = (provider: ServerProvider) => { + const displayName = provider.displayName?.trim(); + return ( + (displayName && !displayName.includes("@") ? displayName : undefined) || + driverLabel(provider.driver) || + String(provider.driver) + ); + }; + for (const group of collectLimitsGroups(presentations)) { + for (const provider of group.providers) { + if (!provider.usageLimits) continue; + const label = providerLimitsLabel(provider); + add( + group.environmentLabel ? `${group.environmentLabel} · ${label}` : label, + provider.usageLimits, + ); + } + } + for (const source of collectLimitSources(presentations)) { + for (const [index, account] of source.accounts.entries()) { + add( + `${source.label} · ${driverLabel(account.driver) ?? account.driver} ${index + 1}`, + account.usageLimits, + Boolean(source.error), + ); + } + if (source.error && source.accounts.length === 0) { + add(source.label, { checkedAt: source.checkedAt, windows: [] }); + } + } + // Most constrained windows stay visible in the smallest families. Stable + // sorting preserves account order when two windows have the same quota. + rows.sort((a, b) => (b.usedPercent ?? -1) - (a.usedPercent ?? -1)); + return { rows: rows.slice(0, 8), totalRows: rows.length, deepLink }; +} + +/** Schedule expiry without pretending that a reset supplies a fresh quota reading. */ +export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) { + const dates = [ + now, + ...new Set(snapshot.rows.map((row) => row.expiresAt).filter((at) => at > now)), + ]; + return dates.sort((a, b) => a - b).map((at) => ({ date: new Date(at), props: snapshot })); +} diff --git a/docs/user/usage.md b/docs/user/usage.md index fba493156dc2..43c364e7452a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -75,3 +75,22 @@ account and choose **Use reset** to redeem one. No hub plugin is required. This connection supplies usage information; configure the provider separately to send agent requests through the hub. Remove the hub from the same settings section when you no longer need it. + +## Add subscription usage to your home screen + +On iOS or Android, open T3 Code and connect your environments, then add the **Subscription usage** +widget from your phone's widget gallery. On iOS, choose small, medium, or large. On Android, +resize the widget to show more quota windows. Tap it to open **Usage → Limits**. + +The widget shows saved quota percentages, reset times, and when the displayed data was checked. +The most-used quota windows appear first; a “more” count indicates additional windows in the app. +Account email addresses are omitted. Providers without subscription usage data do not appear. + +Data updates while the app receives information from connected environments. The widget does +not fetch quotas while the app is closed. After thirty minutes or a reported reset time, it asks +you to open the app to refresh; Android may show this notice at its next system widget update. +Pull to refresh on **Limits** to request a new reading. A reset never makes the saved percentage +zero automatically. Removing an environment in the app removes its data from the widget. + +If the widget is missing from the gallery, update the installed app and open it once. Widgets +require an app build that includes them; iOS builds without widget extensions do not offer them.