| author | |
| committer | |
| log | 17f8e2a8acba76d5516e0a52c85bf802b0e17892 |
| tree | f2601a53c83c92a727dba483c87e1199388688fc |
| parent | 0a91b9a116117dd5df619fb7c1248cba9cf6f43c |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 1085 insertions(+), 300 deletions(-)
apps/mobile/app.config.ts+39| ... | ... | @@ -130,6 +130,45 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [ |
| 130 | 130 | // frequent-updates entitlement iOS throttles the update budget sooner. |
| 131 | 131 | frequentUpdates: true, |
| 132 | 132 | widgets: [ |
| 133 | { | |
| 134 | name: "SubscriptionUsage", | |
| 135 | displayName: "Subscription usage", | |
| 136 | description: "Subscription quotas from your connected T3 Code environments.", | |
| 137 | configuration: { | |
| 138 | title: "Subscription usage", | |
| 139 | description: | |
| 140 | "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.", | |
| 141 | parameters: { | |
| 142 | codexPeriod: { | |
| 143 | title: "Codex limits", | |
| 144 | type: "enum", | |
| 145 | default: "auto", | |
| 146 | values: [ | |
| 147 | { name: "Both", value: "auto" }, | |
| 148 | { name: "Session", value: "session" }, | |
| 149 | { name: "Weekly", value: "weekly" }, | |
| 150 | ], | |
| 151 | }, | |
| 152 | claudePeriod: { | |
| 153 | title: "Claude limits", | |
| 154 | type: "enum", | |
| 155 | default: "auto", | |
| 156 | values: [ | |
| 157 | { name: "Both", value: "auto" }, | |
| 158 | { name: "Session", value: "session" }, | |
| 159 | { name: "Weekly", value: "weekly" }, | |
| 160 | ], | |
| 161 | }, | |
| 162 | }, | |
| 163 | }, | |
| 164 | supportedFamilies: [ | |
| 165 | "systemSmall", | |
| 166 | "systemMedium", | |
| 167 | "systemLarge", | |
| 168 | "systemExtraLarge", | |
| 169 | "accessoryRectangular", | |
| 170 | ], | |
| 171 | }, | |
| 133 | 172 | { |
| 134 | 173 | name: "AgentActivity", |
| 135 | 174 | displayName: "Agent Activity", |
apps/mobile/modules/t3-subscription-widget/android/build.gradle created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | apply plugin: 'com.android.library' | |
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | |
| 3 | ||
| 4 | group = 'com.t3tools.subscriptionwidget' | |
| 5 | version = '0.0.0' | |
| 6 | ||
| 7 | android { | |
| 8 | namespace 'expo.modules.t3subscriptionwidget' | |
| 9 | compileSdk rootProject.ext.compileSdkVersion | |
| 10 | defaultConfig { | |
| 11 | minSdkVersion rootProject.ext.minSdkVersion | |
| 12 | targetSdkVersion rootProject.ext.targetSdkVersion | |
| 13 | } | |
| 14 | } | |
| 15 | ||
| 16 | dependencies { | |
| 17 | implementation project(':expo-modules-core') | |
| 18 | } |
apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 2 | <application> | |
| 3 | <receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:exported="false" android:label="@string/t3_subscription_widget_name"> | |
| 4 | <intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter> | |
| 5 | <meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" /> | |
| 6 | </receiver> | |
| 7 | </application> | |
| 8 | </manifest> |
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt created+163| ... | ... | @@ -0,0 +1,163 @@ |
| 1 | package expo.modules.t3subscriptionwidget | |
| 2 | ||
| 3 | import android.app.AlarmManager | |
| 4 | import android.app.PendingIntent | |
| 5 | import android.appwidget.AppWidgetManager | |
| 6 | import android.appwidget.AppWidgetProvider | |
| 7 | import android.content.ComponentName | |
| 8 | import android.content.Context | |
| 9 | import android.content.Intent | |
| 10 | import android.net.Uri | |
| 11 | import android.os.Bundle | |
| 12 | import android.view.View | |
| 13 | import android.widget.RemoteViews | |
| 14 | import org.json.JSONObject | |
| 15 | import java.text.DateFormat | |
| 16 | import java.util.Date | |
| 17 | ||
| 18 | class SubscriptionUsageWidget : AppWidgetProvider() { | |
| 19 | override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { | |
| 20 | ids.forEach { update(context, manager, it) } | |
| 21 | } | |
| 22 | ||
| 23 | override fun onReceive(context: Context, intent: Intent) { | |
| 24 | super.onReceive(context, intent) | |
| 25 | if (intent.action == EXPIRE) updateAll(context) | |
| 26 | } | |
| 27 | ||
| 28 | override fun onDisabled(context: Context) { | |
| 29 | context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) | |
| 30 | } | |
| 31 | ||
| 32 | override fun onAppWidgetOptionsChanged( | |
| 33 | context: Context, | |
| 34 | manager: AppWidgetManager, | |
| 35 | id: Int, | |
| 36 | options: Bundle | |
| 37 | ) { | |
| 38 | update(context, manager, id) | |
| 39 | } | |
| 40 | ||
| 41 | companion object { | |
| 42 | const val PREFERENCES = "t3_subscription_widget" | |
| 43 | private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE" | |
| 44 | ||
| 45 | private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( | |
| 46 | context, | |
| 47 | 0, | |
| 48 | Intent(context, SubscriptionUsageWidget::class.java).setAction(EXPIRE), | |
| 49 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | |
| 50 | ) | |
| 51 | ||
| 52 | fun updateAll(context: Context) { | |
| 53 | val manager = AppWidgetManager.getInstance(context) | |
| 54 | manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java)) | |
| 55 | .forEach { update(context, manager, it) } | |
| 56 | } | |
| 57 | ||
| 58 | private fun update(context: Context, manager: AppWidgetManager, id: Int) { | |
| 59 | val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) | |
| 60 | val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull() | |
| 61 | val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) | |
| 62 | openAppIntent(context, id, snapshot)?.let { | |
| 63 | views.setOnClickPendingIntent(R.id.t3_widget_root, it) | |
| 64 | } | |
| 65 | val providers = snapshot?.optJSONArray("providers") | |
| 66 | val now = System.currentTimeMillis() | |
| 67 | var nextExpiry = Long.MAX_VALUE | |
| 68 | var totalRows = 0 | |
| 69 | val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index -> | |
| 70 | val provider = providers?.optJSONObject(index) ?: return@mapNotNull null | |
| 71 | val windows = provider.optJSONArray("windows") | |
| 72 | val expiresAt = provider.optLong("expiresAt") | |
| 73 | if (expiresAt > now && windows != null && windows.length() > 0) { | |
| 74 | nextExpiry = minOf(nextExpiry, expiresAt) | |
| 75 | totalRows += provider.optInt("totalWindows", windows.length()) | |
| 76 | (0 until windows.length()).map { provider to windows.optJSONObject(it) } | |
| 77 | } else { | |
| 78 | totalRows++ | |
| 79 | listOf(provider to null) | |
| 80 | } | |
| 81 | } | |
| 82 | // Show each provider before filling spare space with its other windows. | |
| 83 | val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index -> | |
| 84 | groups.mapNotNull { it.getOrNull(index) } | |
| 85 | } | |
| 86 | if (rows.isNotEmpty()) { | |
| 87 | views.removeAllViews(R.id.t3_widget_rows) | |
| 88 | val options = manager.getAppWidgetOptions(id) | |
| 89 | val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) | |
| 90 | val count = ((height - 64) / 66).coerceIn(1, 12).coerceAtMost(rows.size) | |
| 91 | for ((provider, window) in rows.take(count)) { | |
| 92 | views.addView(R.id.t3_widget_rows, rowView(context, provider, window)) | |
| 93 | } | |
| 94 | val remaining = totalRows - count | |
| 95 | val checkedAt = snapshot?.optLong("checkedAt") ?: 0 | |
| 96 | val formatted = DateFormat.getDateTimeInstance( | |
| 97 | DateFormat.SHORT, | |
| 98 | DateFormat.SHORT | |
| 99 | ).format(Date(checkedAt)) | |
| 100 | val more = if (remaining > 0) { | |
| 101 | context.getString(R.string.t3_subscription_widget_more, remaining) | |
| 102 | } else { | |
| 103 | "" | |
| 104 | } | |
| 105 | val checked = if (checkedAt > 0) { | |
| 106 | context.getString(R.string.t3_subscription_widget_as_of, formatted) | |
| 107 | } else { | |
| 108 | context.getString(R.string.t3_subscription_widget_unknown_check) | |
| 109 | } | |
| 110 | views.setTextViewText(R.id.t3_widget_footer, checked + more) | |
| 111 | } | |
| 112 | val alarms = context.getSystemService(AlarmManager::class.java) | |
| 113 | alarms.cancel(expiryIntent(context)) | |
| 114 | // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry. | |
| 115 | if (nextExpiry != Long.MAX_VALUE) { | |
| 116 | alarms.set(AlarmManager.RTC, nextExpiry, expiryIntent(context)) | |
| 117 | } | |
| 118 | manager.updateAppWidget(id, views) | |
| 119 | } | |
| 120 | ||
| 121 | private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? { | |
| 122 | // Target this variant's launcher so co-installed builds cannot steal the tap. | |
| 123 | val intent = | |
| 124 | context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null | |
| 125 | intent.action = Intent.ACTION_VIEW | |
| 126 | val deepLink = snapshot?.optString("url")?.takeIf { it.isNotBlank() } | |
| 127 | ?: "t3code://settings/usage?tab=limits" | |
| 128 | intent.data = Uri.parse(deepLink) | |
| 129 | intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP | |
| 130 | return PendingIntent.getActivity( | |
| 131 | context, | |
| 132 | id, | |
| 133 | intent, | |
| 134 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | |
| 135 | ) | |
| 136 | } | |
| 137 | ||
| 138 | private fun rowView(context: Context, provider: JSONObject, window: JSONObject?): RemoteViews { | |
| 139 | val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) | |
| 140 | val remaining = window?.optInt("remaining")?.coerceIn(0, 100) | |
| 141 | val detail = provider.optString("detail") | |
| 142 | val label = provider.optString("name") | |
| 143 | val windowLabel = window?.optString("label") ?: detail | |
| 144 | child.setTextViewText(R.id.t3_widget_label, label) | |
| 145 | child.setTextViewText(R.id.t3_widget_window, windowLabel) | |
| 146 | val percent = remaining?.let { | |
| 147 | context.getString(R.string.t3_subscription_widget_remaining, it) | |
| 148 | } ?: "—" | |
| 149 | child.setTextViewText(R.id.t3_widget_percent, percent) | |
| 150 | val visibility = if (remaining == null) View.GONE else View.VISIBLE | |
| 151 | child.setViewVisibility(R.id.t3_widget_progress, visibility) | |
| 152 | if (remaining != null) child.setProgressBar(R.id.t3_widget_progress, 100, remaining, false) | |
| 153 | val reset = window?.optString("reset") | |
| 154 | ?: context.getString(R.string.t3_subscription_widget_refresh) | |
| 155 | child.setTextViewText(R.id.t3_widget_reset, reset) | |
| 156 | child.setContentDescription( | |
| 157 | R.id.t3_widget_row, | |
| 158 | "$label. $windowLabel. $percent. $reset. $detail" | |
| 159 | ) | |
| 160 | return child | |
| 161 | } | |
| 162 | } | |
| 163 | } |
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | package expo.modules.t3subscriptionwidget | |
| 2 | ||
| 3 | import expo.modules.kotlin.modules.Module | |
| 4 | import expo.modules.kotlin.modules.ModuleDefinition | |
| 5 | import org.json.JSONObject | |
| 6 | ||
| 7 | class T3SubscriptionWidgetModule : Module() { | |
| 8 | override fun definition() = ModuleDefinition { | |
| 9 | Name("T3SubscriptionWidget") | |
| 10 | Function("updateSnapshot") { snapshot: String -> | |
| 11 | val context = appContext.reactContext ?: return@Function | |
| 12 | JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot. | |
| 13 | context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0) | |
| 14 | .edit().putString("snapshot", snapshot).apply() | |
| 15 | SubscriptionUsageWidget.updateAll(context) | |
| 16 | } | |
| 17 | } | |
| 18 | } |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <shape xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 2 | <solid android:color="@color/t3_widget_background" /> | |
| 3 | <corners android:radius="20dp" /> | |
| 4 | </shape> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
| 2 | android:id="@+id/t3_widget_root" android:layout_width="match_parent" android:layout_height="match_parent" | |
| 3 | android:orientation="vertical" android:padding="16dp" android:background="@drawable/t3_subscription_widget_background"> | |
| 4 | <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_name" android:textStyle="bold" android:textSize="16sp" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" /> | |
| 5 | <LinearLayout android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="vertical"> | |
| 6 | <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/t3_subscription_widget_empty" android:textSize="13sp" android:textColor="@color/t3_widget_secondary" /> | |
| 7 | </LinearLayout> | |
| 8 | <TextView android:id="@+id/t3_widget_footer" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_open" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" /> | |
| 9 | </LinearLayout> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
| 2 | android:id="@+id/t3_widget_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingTop="8dp"> | |
| 3 | <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> | |
| 4 | <TextView android:id="@+id/t3_widget_label" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:textSize="12sp" android:textStyle="bold" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" /> | |
| 5 | <TextView android:id="@+id/t3_widget_percent" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingStart="8dp" android:textSize="12sp" android:textColor="@color/t3_widget_foreground" /> | |
| 6 | </LinearLayout> | |
| 7 | <TextView android:id="@+id/t3_widget_window" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" /> | |
| 8 | <ProgressBar android:id="@+id/t3_widget_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="match_parent" android:layout_height="6dp" android:max="100" android:progressTint="#0284c7" /> | |
| 9 | <TextView android:id="@+id/t3_widget_reset" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" /> | |
| 10 | </LinearLayout> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml created+5| ... | ... | @@ -0,0 +1,5 @@ |
| 1 | <resources> | |
| 2 | <color name="t3_widget_background">#18181B</color> | |
| 3 | <color name="t3_widget_foreground">#FAFAFA</color> | |
| 4 | <color name="t3_widget_secondary">#A1A1AA</color> | |
| 5 | </resources> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml created+5| ... | ... | @@ -0,0 +1,5 @@ |
| 1 | <resources> | |
| 2 | <color name="t3_widget_background">#FAFAFA</color> | |
| 3 | <color name="t3_widget_foreground">#18181B</color> | |
| 4 | <color name="t3_widget_secondary">#52525B</color> | |
| 5 | </resources> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | <resources> | |
| 2 | <string name="t3_subscription_widget_unknown_check">Last checked unavailable</string> | |
| 3 | <string name="t3_subscription_widget_name">Subscription usage</string> | |
| 4 | <string name="t3_subscription_widget_description">Saved subscription quotas from your T3 Code environments. Tap to refresh in the app.</string> | |
| 5 | <string name="t3_subscription_widget_empty">Open T3 Code and connect an environment to see limits.</string> | |
| 6 | <string name="t3_subscription_widget_open">Tap to open Usage</string> | |
| 7 | <string name="t3_subscription_widget_refresh">Open app to refresh</string> | |
| 8 | <string name="t3_subscription_widget_remaining">%1$d%% remaining</string> | |
| 9 | <string name="t3_subscription_widget_as_of">As of %1$s</string> | |
| 10 | <string name="t3_subscription_widget_more"> · +%1$d more</string> | |
| 11 | </resources> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" | |
| 2 | android:minWidth="250dp" android:minHeight="180dp" | |
| 3 | android:minResizeWidth="180dp" android:minResizeHeight="130dp" | |
| 4 | android:targetCellWidth="4" android:targetCellHeight="3" | |
| 5 | android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" | |
| 6 | android:updatePeriodMillis="1800000" | |
| 7 | android:description="@string/t3_subscription_widget_description" | |
| 8 | android:initialLayout="@layout/t3_subscription_widget" | |
| 9 | android:previewLayout="@layout/t3_subscription_widget" /> |
apps/mobile/modules/t3-subscription-widget/expo-module.config.json created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | { | |
| 2 | "platforms": ["android"], | |
| 3 | "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] } | |
| 4 | } |
apps/mobile/src/App.tsx+3| ... | ... | @@ -24,6 +24,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal"; |
| 24 | 24 | import { appBlurTargetRef } from "./lib/appBlurTarget"; |
| 25 | 25 | import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; |
| 26 | 26 | |
| 27 | import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; | |
| 28 | ||
| 27 | 29 | import "../global.css"; |
| 28 | 30 | |
| 29 | 31 | if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { |
| ... | ... | @@ -77,6 +79,7 @@ function AppContent() { |
| 77 | 79 | return ( |
| 78 | 80 | <> |
| 79 | 81 | <SplashScreenCoordinator /> |
| 82 | <SubscriptionUsageCoordinator /> | |
| 80 | 83 | <GestureHandlerRootView className="flex-1"> |
| 81 | 84 | <KeyboardProvider statusBarTranslucent> |
| 82 | 85 | <SafeAreaProvider> |
apps/mobile/src/features/usage/UsageRouteScreen.tsx+15-4| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; | |
| 3 | 3 | import { |
| 4 | 4 | isCompatibleUsageContractVersion, |
| 5 | 5 | isModelCostUnknown, |
| ... | ... | @@ -65,11 +65,22 @@ const CHART_HEIGHT = 180; |
| 65 | 65 | * pull to refresh, each refreshing its own data. |
| 66 | 66 | */ |
| 67 | 67 | export function UsageRouteScreen() { |
| 68 | const route = useRoute<RouteProp<{ Usage: { tab?: string } | undefined }, "Usage">>(); | |
| 68 | 69 | const navigation = useNavigation(); |
| 69 | 70 | const insets = useSafeAreaInsets(); |
| 70 | // Limits first: remaining quota and reset time are what most people open | |
| 71 | // the screen for. | |
| 72 | const [tab, setTab] = useState<UsageTab>("limits"); | |
| 71 | // Preserve the Limits default while honoring explicit widget/navigation links. | |
| 72 | const [selection, setSelection] = useState(() => ({ | |
| 73 | params: route.params, | |
| 74 | tab: (route.params?.tab === "usage" ? "usage" : "limits") as UsageTab, | |
| 75 | })); | |
| 76 | if (selection.params !== route.params) { | |
| 77 | setSelection({ | |
| 78 | params: route.params, | |
| 79 | tab: route.params?.tab === "usage" ? "usage" : "limits", | |
| 80 | }); | |
| 81 | } | |
| 82 | const { tab } = selection; | |
| 83 | const setTab = (tab: UsageTab) => setSelection({ params: route.params, tab }); | |
| 73 | 84 | const [windowSelection, setWindowSelection] = useState(() => ({ |
| 74 | 85 | days: 30, |
| 75 | 86 | window: makeWindow(30), |
apps/mobile/src/widgets/SubscriptionUsage.tsx created+262| ... | ... | @@ -0,0 +1,262 @@ |
| 1 | import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui"; | |
| 2 | import { | |
| 3 | accessibilityElement, | |
| 4 | accessibilityLabel, | |
| 5 | font, | |
| 6 | foregroundStyle, | |
| 7 | frame, | |
| 8 | layoutPriority, | |
| 9 | lineLimit, | |
| 10 | minimumScaleFactor, | |
| 11 | progressViewStyle, | |
| 12 | tint, | |
| 13 | widgetURL, | |
| 14 | } from "@expo/ui/swift-ui/modifiers"; | |
| 15 | import { createWidget, type WidgetEnvironment } from "expo-widgets"; | |
| 16 | ||
| 17 | import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot"; | |
| 18 | ||
| 19 | type UsageConfiguration = { | |
| 20 | codexPeriod?: "auto" | "session" | "weekly"; | |
| 21 | claudePeriod?: "auto" | "session" | "weekly"; | |
| 22 | }; | |
| 23 | ||
| 24 | function SubscriptionUsage( | |
| 25 | props: SubscriptionUsageProps, | |
| 26 | environment: WidgetEnvironment<UsageConfiguration>, | |
| 27 | ) { | |
| 28 | "widget"; | |
| 29 | // The extension evaluates this function without the app's module scope. | |
| 30 | const family = environment.widgetFamily; | |
| 31 | // Gallery snapshots can render an old timeline entry after it has expired. | |
| 32 | const now = Math.max(environment.date.getTime(), Date.now()); | |
| 33 | const accessory = family === "accessoryRectangular"; | |
| 34 | const compact = | |
| 35 | family === "systemSmall" || accessory || environment.levelOfDetail === "simplified"; | |
| 36 | const limit = family === "systemExtraLarge" ? 6 : family === "systemLarge" ? 4 : 2; | |
| 37 | const monochrome = | |
| 38 | environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; | |
| 39 | const providers = props.providers ?? [ | |
| 40 | { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0 }, | |
| 41 | { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0 }, | |
| 42 | ]; | |
| 43 | const columns = providers.map((provider) => { | |
| 44 | const stale = provider.windows.length > 0 && now >= provider.expiresAt; | |
| 45 | const period = | |
| 46 | environment.configuration?.[provider.name === "Claude" ? "claudePeriod" : "codexPeriod"] ?? | |
| 47 | "auto"; | |
| 48 | const windows = stale | |
| 49 | ? [] | |
| 50 | : provider.windows.filter((window) => period === "auto" || window.kind === period); | |
| 51 | // Lock Screen widgets surface the tightest selected limit. | |
| 52 | const tightest = windows.reduce<(typeof windows)[number] | undefined>( | |
| 53 | (result, window) => (!result || window.remaining < result.remaining ? window : result), | |
| 54 | undefined, | |
| 55 | ); | |
| 56 | const compactWindows = [ | |
| 57 | windows.find((window) => window.kind === "session"), | |
| 58 | windows.find((window) => window.kind === "weekly"), | |
| 59 | ].filter((window) => window !== undefined); | |
| 60 | const shown = | |
| 61 | accessory || environment.levelOfDetail === "simplified" | |
| 62 | ? tightest | |
| 63 | ? [tightest] | |
| 64 | : [] | |
| 65 | : family === "systemSmall" && compactWindows.length > 0 | |
| 66 | ? compactWindows | |
| 67 | : period === "auto" && compactWindows.length > 0 | |
| 68 | ? [ | |
| 69 | ...compactWindows, | |
| 70 | ...windows.filter((window) => !compactWindows.includes(window)), | |
| 71 | ].slice(0, limit) | |
| 72 | : windows.slice(0, limit); | |
| 73 | const detail = stale | |
| 74 | ? "Open T3 to refresh" | |
| 75 | : period !== "auto" && windows.length === 0 && provider.windows.length > 0 | |
| 76 | ? `No ${period} limit reported` | |
| 77 | : provider.detail; | |
| 78 | const barModifiers = [ | |
| 79 | progressViewStyle("linear"), | |
| 80 | ...(monochrome ? [] : [tint(provider.name === "Claude" ? "#d97757" : "#8e8e93")]), | |
| 81 | ]; | |
| 82 | if (accessory) { | |
| 83 | return ( | |
| 84 | <VStack | |
| 85 | key={provider.name} | |
| 86 | alignment="leading" | |
| 87 | spacing={2} | |
| 88 | modifiers={[ | |
| 89 | accessibilityElement("ignore"), | |
| 90 | accessibilityLabel( | |
| 91 | tightest | |
| 92 | ? `${provider.name}, ${tightest.label}, ${tightest.remaining} percent remaining. ${tightest.reset}. ${provider.detail}.` | |
| 93 | : `${provider.name}. ${detail}.`, | |
| 94 | ), | |
| 95 | ]} | |
| 96 | > | |
| 97 | <HStack spacing={4}> | |
| 98 | <Text | |
| 99 | modifiers={[ | |
| 100 | font({ textStyle: "caption", weight: "semibold" }), | |
| 101 | lineLimit(1), | |
| 102 | minimumScaleFactor(0.75), | |
| 103 | foregroundStyle("primary"), | |
| 104 | ]} | |
| 105 | > | |
| 106 | {provider.name} | |
| 107 | {tightest ? ` · ${tightest.label}` : ""} | |
| 108 | </Text> | |
| 109 | <Spacer /> | |
| 110 | <Text | |
| 111 | modifiers={[ | |
| 112 | font({ textStyle: "caption", weight: "semibold" }), | |
| 113 | lineLimit(1), | |
| 114 | layoutPriority(1), | |
| 115 | foregroundStyle("primary"), | |
| 116 | ]} | |
| 117 | > | |
| 118 | {tightest | |
| 119 | ? `${tightest.remaining}% left` | |
| 120 | : period !== "auto" && !stale && provider.windows.length > 0 | |
| 121 | ? "N/A" | |
| 122 | : "Open T3"} | |
| 123 | </Text> | |
| 124 | </HStack> | |
| 125 | {tightest ? ( | |
| 126 | <ProgressView value={tightest.remaining / 100} modifiers={barModifiers} /> | |
| 127 | ) : null} | |
| 128 | </VStack> | |
| 129 | ); | |
| 130 | } | |
| 131 | return ( | |
| 132 | <VStack | |
| 133 | key={provider.name} | |
| 134 | alignment="leading" | |
| 135 | spacing={compact ? 2 : 4} | |
| 136 | modifiers={[frame({ maxWidth: Infinity, alignment: "leading" })]} | |
| 137 | > | |
| 138 | <Text | |
| 139 | modifiers={[ | |
| 140 | font({ textStyle: compact ? "caption" : "headline", weight: "bold" }), | |
| 141 | lineLimit(1), | |
| 142 | minimumScaleFactor(0.75), | |
| 143 | foregroundStyle("primary"), | |
| 144 | ]} | |
| 145 | > | |
| 146 | {provider.name} | |
| 147 | </Text> | |
| 148 | {(!compact || shown.length === 0) && detail !== "Subscription remaining" ? ( | |
| 149 | <Text | |
| 150 | modifiers={[ | |
| 151 | font({ textStyle: "caption2" }), | |
| 152 | foregroundStyle("secondary"), | |
| 153 | lineLimit(compact ? 1 : 2), | |
| 154 | ]} | |
| 155 | > | |
| 156 | {detail} | |
| 157 | </Text> | |
| 158 | ) : null} | |
| 159 | {shown.map((window) => ( | |
| 160 | <VStack | |
| 161 | key={window.label} | |
| 162 | alignment="leading" | |
| 163 | spacing={2} | |
| 164 | modifiers={[ | |
| 165 | accessibilityElement("ignore"), | |
| 166 | accessibilityLabel( | |
| 167 | `${provider.name}, ${window.label}, ${window.remaining} percent remaining. ${window.reset}. ${provider.detail}.`, | |
| 168 | ), | |
| 169 | ]} | |
| 170 | > | |
| 171 | <HStack spacing={4}> | |
| 172 | <Text | |
| 173 | modifiers={[ | |
| 174 | font({ textStyle: compact ? "caption2" : "caption" }), | |
| 175 | foregroundStyle("secondary"), | |
| 176 | lineLimit(1), | |
| 177 | minimumScaleFactor(0.75), | |
| 178 | ]} | |
| 179 | > | |
| 180 | {window.label} | |
| 181 | </Text> | |
| 182 | <Spacer /> | |
| 183 | <Text | |
| 184 | modifiers={[ | |
| 185 | font({ textStyle: compact ? "caption2" : "caption", weight: "semibold" }), | |
| 186 | lineLimit(1), | |
| 187 | minimumScaleFactor(0.75), | |
| 188 | layoutPriority(1), | |
| 189 | foregroundStyle( | |
| 190 | window.remaining <= 10 && !monochrome | |
| 191 | ? environment.colorScheme === "light" | |
| 192 | ? "#dc2626" | |
| 193 | : "#fca5a5" | |
| 194 | : "primary", | |
| 195 | ), | |
| 196 | ]} | |
| 197 | > | |
| 198 | {window.remaining}% left | |
| 199 | </Text> | |
| 200 | </HStack> | |
| 201 | <ProgressView value={window.remaining / 100} modifiers={barModifiers} /> | |
| 202 | {!compact ? ( | |
| 203 | <Text | |
| 204 | modifiers={[ | |
| 205 | font({ textStyle: "caption2" }), | |
| 206 | foregroundStyle("secondary"), | |
| 207 | lineLimit(1), | |
| 208 | minimumScaleFactor(0.75), | |
| 209 | ]} | |
| 210 | > | |
| 211 | {window.reset} | |
| 212 | </Text> | |
| 213 | ) : null} | |
| 214 | </VStack> | |
| 215 | ))} | |
| 216 | {!compact && | |
| 217 | !stale && | |
| 218 | (period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) > limit ? ( | |
| 219 | <Text modifiers={[font({ textStyle: "caption2" }), foregroundStyle("secondary")]}> | |
| 220 | {(period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) - | |
| 221 | limit}{" "} | |
| 222 | more in T3 | |
| 223 | </Text> | |
| 224 | ) : null} | |
| 225 | </VStack> | |
| 226 | ); | |
| 227 | }); | |
| 228 | return ( | |
| 229 | <VStack | |
| 230 | alignment="leading" | |
| 231 | spacing={accessory ? 2 : 6} | |
| 232 | modifiers={props.url ? [widgetURL(props.url)] : []} | |
| 233 | > | |
| 234 | {compact ? ( | |
| 235 | <VStack alignment="leading" spacing={accessory ? 4 : 8}> | |
| 236 | {columns} | |
| 237 | </VStack> | |
| 238 | ) : ( | |
| 239 | <HStack alignment="top" spacing={16}> | |
| 240 | {columns} | |
| 241 | </HStack> | |
| 242 | )} | |
| 243 | {!accessory ? <Spacer /> : null} | |
| 244 | {!accessory ? ( | |
| 245 | <Text | |
| 246 | modifiers={[ | |
| 247 | font({ textStyle: "caption2" }), | |
| 248 | foregroundStyle("secondary"), | |
| 249 | lineLimit(1), | |
| 250 | minimumScaleFactor(0.75), | |
| 251 | ]} | |
| 252 | > | |
| 253 | {props.checkedAt | |
| 254 | ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}` | |
| 255 | : "Tap to connect in T3"} | |
| 256 | </Text> | |
| 257 | ) : null} | |
| 258 | </VStack> | |
| 259 | ); | |
| 260 | } | |
| 261 | ||
| 262 | export default createWidget("SubscriptionUsage", SubscriptionUsage); |
apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { Atom } from "effect/unstable/reactivity"; | |
| 3 | import * as Linking from "expo-linking"; | |
| 4 | import { useEffect } from "react"; | |
| 5 | import { environmentCatalog } from "../connection/catalog"; | |
| 6 | import { environmentPresentations } from "../state/presentation"; | |
| 7 | import { publishSubscriptionUsage } from "./publishSubscriptionUsage"; | |
| 8 | import { useSubscriptionUsage } from "./useSubscriptionUsage"; | |
| 9 | import { buildSubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; | |
| 10 | ||
| 11 | // Isolate quota changes from the much busier thread/config presentation stream. | |
| 12 | const snapshotAtom = Atom.make((get) => | |
| 13 | buildSubscriptionUsageSnapshot( | |
| 14 | get(environmentPresentations.presentationsAtom), | |
| 15 | Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }), | |
| 16 | ), | |
| 17 | ).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); | |
| 18 | ||
| 19 | export function SubscriptionUsageCoordinator() { | |
| 20 | const catalog = useAtomValue(environmentCatalog.catalogValueAtom); | |
| 21 | const snapshot = useAtomValue(snapshotAtom); | |
| 22 | useSubscriptionUsage(catalog.isReady); | |
| 23 | useEffect(() => { | |
| 24 | if (!catalog.isReady) return; | |
| 25 | void Promise.resolve() | |
| 26 | .then(() => publishSubscriptionUsage(snapshot)) | |
| 27 | .catch((error: unknown) => { | |
| 28 | console.warn("Could not update subscription usage widget", error); | |
| 29 | }); | |
| 30 | }, [catalog.isReady, snapshot]); | |
| 31 | return null; | |
| 32 | } |
apps/mobile/src/widgets/publishSubscriptionUsage.android.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | import { requireOptionalNativeModule } from "expo"; | |
| 2 | import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; | |
| 3 | ||
| 4 | export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { | |
| 5 | requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>( | |
| 6 | "T3SubscriptionWidget", | |
| 7 | )?.updateSnapshot(JSON.stringify(snapshot)); | |
| 8 | } |
apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import { requireOptionalNativeModule } from "expo"; | |
| 2 | import { | |
| 3 | subscriptionUsageTimeline, | |
| 4 | type SubscriptionUsageSnapshot, | |
| 5 | } from "./subscriptionUsageSnapshot"; | |
| 6 | ||
| 7 | export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { | |
| 8 | if (!requireOptionalNativeModule("ExpoWidgets")) return; | |
| 9 | const { default: widget } = await import("./SubscriptionUsage"); | |
| 10 | widget.updateTimeline(subscriptionUsageTimeline(snapshot, Date.now())); | |
| 11 | } |
apps/mobile/src/widgets/publishSubscriptionUsage.ts created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; | |
| 2 | ||
| 3 | export function publishSubscriptionUsage(_snapshot: SubscriptionUsageSnapshot) {} |
apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts created+257| ... | ... | @@ -0,0 +1,257 @@ |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | ProviderDriverKind, | |
| 4 | ProviderInstanceId, | |
| 5 | UsageLimitSourceId, | |
| 6 | type ServerProvider, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 9 | import { | |
| 10 | buildSubscriptionUsageSnapshot, | |
| 11 | createWidgetRefresher, | |
| 12 | WIDGET_REFRESH_INTERVAL, | |
| 13 | subscriptionUsageTimeline, | |
| 14 | } from "./subscriptionUsageSnapshot"; | |
| 15 | ||
| 16 | const checkedAt = "2026-09-05T12:00:00.000Z"; | |
| 17 | const now = Date.parse(checkedAt); | |
| 18 | const window = { | |
| 19 | id: "session", | |
| 20 | kind: "session", | |
| 21 | label: "5 hours", | |
| 22 | usedPercent: 40, | |
| 23 | resetsAt: "2026-09-05T12:10:00.000Z", | |
| 24 | } as const; | |
| 25 | const limits = { checkedAt, windows: [window] }; | |
| 26 | const deepLink = "t3code-dev://settings/usage?tab=limits"; | |
| 27 | function provider(overrides: Partial<ServerProvider> = {}): ServerProvider { | |
| 28 | return { | |
| 29 | instanceId: ProviderInstanceId.make("codex"), | |
| 30 | driver: ProviderDriverKind.make("codex"), | |
| 31 | enabled: true, | |
| 32 | installed: true, | |
| 33 | version: null, | |
| 34 | status: "ready", | |
| 35 | auth: { status: "authenticated", email: "private@example.com" }, | |
| 36 | checkedAt, | |
| 37 | models: [], | |
| 38 | slashCommands: [], | |
| 39 | skills: [], | |
| 40 | usageLimits: limits, | |
| 41 | ...overrides, | |
| 42 | }; | |
| 43 | } | |
| 44 | function presentations(providers: readonly ServerProvider[] = [provider()]) { | |
| 45 | return new Map([ | |
| 46 | [ | |
| 47 | EnvironmentId.make("env"), | |
| 48 | { entry: { target: { label: "Remote" } }, serverConfig: { providers } }, | |
| 49 | ], | |
| 50 | ]); | |
| 51 | } | |
| 52 | ||
| 53 | describe("subscription widget snapshots", () => { | |
| 54 | it("uses provider data and its observation time without exposing account emails", () => { | |
| 55 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 56 | presentations([provider({ displayName: "private@example.com" })]), | |
| 57 | deepLink, | |
| 58 | ); | |
| 59 | expect(snapshot.checkedAt).toBe(now); | |
| 60 | expect(snapshot.providers[0]).toMatchObject({ | |
| 61 | name: "Codex", | |
| 62 | windows: [{ remaining: 60 }], | |
| 63 | expiresAt: now + 10 * 60_000, | |
| 64 | }); | |
| 65 | expect(snapshot.url).toBe(deepLink); | |
| 66 | expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); | |
| 67 | }); | |
| 68 | it("clears data after removing environments and hides disabled providers", () => { | |
| 69 | expect( | |
| 70 | buildSubscriptionUsageSnapshot(new Map(), deepLink).providers.every( | |
| 71 | (p) => p.windows.length === 0, | |
| 72 | ), | |
| 73 | ).toBe(true); | |
| 74 | expect( | |
| 75 | buildSubscriptionUsageSnapshot( | |
| 76 | presentations([provider({ enabled: false })]), | |
| 77 | deepLink, | |
| 78 | ).providers.every((p) => p.windows.length === 0), | |
| 79 | ).toBe(true); | |
| 80 | }); | |
| 81 | it("uses upstream deduplication for a native account also present in a proxy hub", () => { | |
| 82 | const input = new Map([ | |
| 83 | [ | |
| 84 | EnvironmentId.make("env"), | |
| 85 | { | |
| 86 | entry: { target: { label: "Remote" } }, | |
| 87 | serverConfig: { | |
| 88 | providers: [provider()], | |
| 89 | usageLimitSources: [ | |
| 90 | { | |
| 91 | id: UsageLimitSourceId.make("hub"), | |
| 92 | kind: "cliproxy" as const, | |
| 93 | label: "Hub", | |
| 94 | checkedAt, | |
| 95 | accounts: [ | |
| 96 | { | |
| 97 | id: "account", | |
| 98 | driver: ProviderDriverKind.make("codex"), | |
| 99 | email: " PRIVATE@example.com ", | |
| 100 | usageLimits: limits, | |
| 101 | }, | |
| 102 | ], | |
| 103 | }, | |
| 104 | ], | |
| 105 | }, | |
| 106 | }, | |
| 107 | ], | |
| 108 | ]); | |
| 109 | expect(buildSubscriptionUsageSnapshot(input, deepLink).providers[0]?.detail).toBe( | |
| 110 | "Subscription remaining", | |
| 111 | ); | |
| 112 | input.get(EnvironmentId.make("env"))!.serverConfig.providers = []; | |
| 113 | const snapshot = buildSubscriptionUsageSnapshot(input, deepLink); | |
| 114 | expect(snapshot.providers[0]?.name).toBe("Codex"); | |
| 115 | expect(JSON.stringify(snapshot)).not.toContain("example.com"); | |
| 116 | }); | |
| 117 | it("keeps unavailable quotas distinct from zero usage and omits provider error messages", () => { | |
| 118 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 119 | presentations([ | |
| 120 | provider({ | |
| 121 | usageLimits: { | |
| 122 | ...limits, | |
| 123 | unavailable: { reason: "probeFailed", message: "token secret" }, | |
| 124 | }, | |
| 125 | }), | |
| 126 | ]), | |
| 127 | deepLink, | |
| 128 | ); | |
| 129 | expect(snapshot.providers[0]?.windows).toEqual([]); | |
| 130 | expect(JSON.stringify(snapshot)).not.toContain("token secret"); | |
| 131 | expect( | |
| 132 | buildSubscriptionUsageSnapshot( | |
| 133 | presentations([ | |
| 134 | provider({ usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 0 }] } }), | |
| 135 | ]), | |
| 136 | deepLink, | |
| 137 | ).providers[0]?.windows[0]?.remaining, | |
| 138 | ).toBe(100); | |
| 139 | }); | |
| 140 | it("bounds OS storage and puts the most constrained windows first", () => { | |
| 141 | const windows = Array.from({ length: 20 }, (_, index) => ({ | |
| 142 | ...window, | |
| 143 | id: `${index}`, | |
| 144 | usedPercent: index * 5, | |
| 145 | })); | |
| 146 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 147 | presentations([provider({ usageLimits: { checkedAt, windows } })]), | |
| 148 | deepLink, | |
| 149 | ); | |
| 150 | expect(snapshot.providers[0]?.windows).toHaveLength(6); | |
| 151 | expect(snapshot.providers[0]?.totalWindows).toBe(20); | |
| 152 | expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5); | |
| 153 | }); | |
| 154 | it("marks unknown or distant reset times stale after fifteen minutes", () => { | |
| 155 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 156 | presentations([ | |
| 157 | provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] } }), | |
| 158 | ]), | |
| 159 | deepLink, | |
| 160 | ); | |
| 161 | expect(snapshot.providers[0]?.expiresAt).toBe(now + 15 * 60_000); | |
| 162 | expect(snapshot.providers[0]?.windows[0]?.reset).toBe("Reset time unavailable"); | |
| 163 | }); | |
| 164 | it("schedules a reset boundary without inventing a zero quota", () => { | |
| 165 | const snapshot = buildSubscriptionUsageSnapshot(presentations(), deepLink); | |
| 166 | const timeline = subscriptionUsageTimeline(snapshot, now); | |
| 167 | expect(timeline.map((entry) => entry.date.getTime())).toEqual([now, now + 10 * 60_000]); | |
| 168 | expect(timeline[1]?.props.providers[0]?.windows).toEqual([]); | |
| 169 | expect(subscriptionUsageTimeline(snapshot, now + 60 * 60_000)).toHaveLength(1); | |
| 170 | }); | |
| 171 | it("expires providers independently without inventing a refill", () => { | |
| 172 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 173 | presentations([ | |
| 174 | provider(), | |
| 175 | provider({ | |
| 176 | instanceId: ProviderInstanceId.make("claude"), | |
| 177 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 178 | usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] }, | |
| 179 | }), | |
| 180 | ]), | |
| 181 | deepLink, | |
| 182 | ); | |
| 183 | const timeline = subscriptionUsageTimeline(snapshot, now); | |
| 184 | expect(timeline.map((entry) => entry.date.getTime())).toEqual([ | |
| 185 | now, | |
| 186 | now + 10 * 60_000, | |
| 187 | now + 15 * 60_000, | |
| 188 | ]); | |
| 189 | expect(timeline[1]?.props.providers[0]?.windows).toEqual([]); | |
| 190 | expect(timeline[1]?.props.providers[1]?.windows[0]?.remaining).toBe(60); | |
| 191 | expect(timeline[2]?.props.providers.every((provider) => provider.windows.length === 0)).toBe( | |
| 192 | true, | |
| 193 | ); | |
| 194 | }); | |
| 195 | it("marks a malformed check time immediately stale without storing null", () => { | |
| 196 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 197 | presentations([provider({ usageLimits: { ...limits, checkedAt: "invalid" } })]), | |
| 198 | deepLink, | |
| 199 | ); | |
| 200 | expect(snapshot.checkedAt).toBe(0); | |
| 201 | expect(snapshot.providers[0]).toMatchObject({ expiresAt: 0, windows: [] }); | |
| 202 | expect(subscriptionUsageTimeline(snapshot, now)).toHaveLength(1); | |
| 203 | expect(JSON.stringify(snapshot)).not.toContain("null"); | |
| 204 | }); | |
| 205 | it("uses the freshest copy of an account across environments before pooling", () => { | |
| 206 | const input = presentations(); | |
| 207 | input.set(EnvironmentId.make("other"), { | |
| 208 | entry: { target: { label: "Other" } }, | |
| 209 | serverConfig: { | |
| 210 | providers: [ | |
| 211 | provider({ | |
| 212 | usageLimits: { | |
| 213 | checkedAt: new Date(now + 60_000).toISOString(), | |
| 214 | windows: [{ ...window, usedPercent: 80 }], | |
| 215 | }, | |
| 216 | }), | |
| 217 | ], | |
| 218 | }, | |
| 219 | }); | |
| 220 | const snapshot = buildSubscriptionUsageSnapshot(input, deepLink); | |
| 221 | expect(snapshot.providers[0]?.detail).toBe("Subscription remaining"); | |
| 222 | expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(20); | |
| 223 | }); | |
| 224 | }); | |
| 225 | ||
| 226 | describe("widget refresh probes", () => { | |
| 227 | it("throttles each connected environment independently and retries failures", async () => { | |
| 228 | const probe = vi | |
| 229 | .fn<(id: string) => Promise<void>>() | |
| 230 | .mockRejectedValueOnce(new Error("offline")) | |
| 231 | .mockResolvedValue(undefined); | |
| 232 | const refresh = createWidgetRefresher(probe); | |
| 233 | await refresh([], now); | |
| 234 | expect(probe).not.toHaveBeenCalled(); | |
| 235 | await refresh(["first"], now); | |
| 236 | await refresh(["first", "second"], now + 1); | |
| 237 | expect(probe.mock.calls).toEqual([["first"], ["second"]]); | |
| 238 | await refresh(["first"], now + WIDGET_REFRESH_INTERVAL); | |
| 239 | expect(probe.mock.calls).toEqual([["first"], ["second"], ["first"]]); | |
| 240 | }); | |
| 241 | ||
| 242 | it("does not overlap a slow probe even after the refresh interval", async () => { | |
| 243 | let finish!: () => void; | |
| 244 | const probe = vi.fn( | |
| 245 | () => | |
| 246 | new Promise<void>((resolve) => { | |
| 247 | finish = resolve; | |
| 248 | }), | |
| 249 | ); | |
| 250 | const refresh = createWidgetRefresher(probe); | |
| 251 | const first = refresh(["one", "one"], now); | |
| 252 | await refresh(["one"], now + WIDGET_REFRESH_INTERVAL); | |
| 253 | expect(probe).toHaveBeenCalledTimes(1); | |
| 254 | finish(); | |
| 255 | await first; | |
| 256 | }); | |
| 257 | }); |
apps/mobile/src/widgets/subscriptionUsageSnapshot.ts created+134| ... | ... | @@ -0,0 +1,134 @@ |
| 1 | import { | |
| 2 | collectLimitAccounts, | |
| 3 | collectLimitPools, | |
| 4 | type LimitAccount, | |
| 5 | type LimitPresentations, | |
| 6 | } from "@t3tools/shared/usageLimits"; | |
| 7 | ||
| 8 | export interface SubscriptionUsageSnapshot { | |
| 9 | url?: string; | |
| 10 | checkedAt: number; | |
| 11 | providers: Array<{ | |
| 12 | name: string; | |
| 13 | detail: string; | |
| 14 | windows: Array<{ kind?: string; label: string; remaining: number; reset: string }>; | |
| 15 | expiresAt: number; | |
| 16 | totalWindows: number; | |
| 17 | }>; | |
| 18 | } | |
| 19 | ||
| 20 | // Snapshots expire after 15 minutes; background refresh needs a | |
| 21 | // separate authenticated transport while the mobile app is suspended. | |
| 22 | const SNAPSHOT_MAX_AGE = 15 * 60_000; | |
| 23 | export const WIDGET_REFRESH_INTERVAL = 5 * 60_000; | |
| 24 | ||
| 25 | /** Bound probes across config updates, reconnects, and foreground transitions. */ | |
| 26 | export function createWidgetRefresher<Id>(refresh: (id: Id) => Promise<unknown>) { | |
| 27 | const attempted = new Map<Id, number>(); | |
| 28 | const pending = new Set<Id>(); | |
| 29 | return async (connected: readonly Id[], now: number) => { | |
| 30 | await Promise.allSettled( | |
| 31 | connected.map(async (id) => { | |
| 32 | if (pending.has(id) || now - (attempted.get(id) ?? -Infinity) < WIDGET_REFRESH_INTERVAL) | |
| 33 | return; | |
| 34 | attempted.set(id, now); | |
| 35 | pending.add(id); | |
| 36 | try { | |
| 37 | await refresh(id); | |
| 38 | } finally { | |
| 39 | pending.delete(id); | |
| 40 | } | |
| 41 | }), | |
| 42 | ); | |
| 43 | }; | |
| 44 | } | |
| 45 | ||
| 46 | function subscriptionUsageProps( | |
| 47 | accounts: readonly LimitAccount[], | |
| 48 | now: number, | |
| 49 | ): SubscriptionUsageSnapshot { | |
| 50 | const pools = collectLimitPools(accounts, now); | |
| 51 | const checked = accounts | |
| 52 | .filter((account) => account.driver === "codex" || account.driver === "claudeAgent") | |
| 53 | .map((account) => Date.parse(account.limits.checkedAt)); | |
| 54 | return { | |
| 55 | checkedAt: checked.length > 0 && checked.every(Number.isFinite) ? Math.min(...checked) : 0, | |
| 56 | providers: (["codex", "claudeAgent"] as const).map((driver) => { | |
| 57 | const pool = pools.find((candidate) => candidate.driver === driver); | |
| 58 | const name = driver === "codex" ? "Codex" : "Claude"; | |
| 59 | if (!pool) | |
| 60 | return { name, detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 }; | |
| 61 | const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); | |
| 62 | const expiresAt = Math.min( | |
| 63 | checkedAt + SNAPSHOT_MAX_AGE, | |
| 64 | ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), | |
| 65 | ); | |
| 66 | const fresh = Number.isFinite(expiresAt) && expiresAt > now; | |
| 67 | const sortedWindows = [...pool.windows].sort( | |
| 68 | (a, b) => a.remainingPercent - b.remainingPercent, | |
| 69 | ); | |
| 70 | // Keep a session and weekly limit when scoped limits fill the storage budget. | |
| 71 | const selectedWindows = [ | |
| 72 | ...new Set([ | |
| 73 | sortedWindows.find((window) => window.kind === "session"), | |
| 74 | sortedWindows.find((window) => window.kind === "weekly"), | |
| 75 | ...sortedWindows, | |
| 76 | ]), | |
| 77 | ] | |
| 78 | .filter((window) => window !== undefined) | |
| 79 | .slice(0, 6) | |
| 80 | .sort((a, b) => a.remainingPercent - b.remainingPercent); | |
| 81 | return { | |
| 82 | name, | |
| 83 | detail: !fresh | |
| 84 | ? "Open T3 to refresh" | |
| 85 | : pool.accounts.length > 1 | |
| 86 | ? `${pool.accounts.length} accounts · pooled` | |
| 87 | : "Subscription remaining", | |
| 88 | expiresAt: fresh ? expiresAt : 0, | |
| 89 | totalWindows: fresh ? pool.windows.length : 0, | |
| 90 | windows: fresh | |
| 91 | ? selectedWindows.map((window) => ({ | |
| 92 | kind: window.kind, | |
| 93 | label: window.label, | |
| 94 | remaining: Math.round(window.remainingPercent), | |
| 95 | reset: window.resets[0] | |
| 96 | ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { | |
| 97 | month: "short", | |
| 98 | day: "numeric", | |
| 99 | hour: "numeric", | |
| 100 | minute: "2-digit", | |
| 101 | })}` | |
| 102 | : "Reset time unavailable", | |
| 103 | })) | |
| 104 | : [], | |
| 105 | }; | |
| 106 | }), | |
| 107 | }; | |
| 108 | } | |
| 109 | ||
| 110 | /** Deduplicate accounts before pooling, and only publish display data to the OS. */ | |
| 111 | export function buildSubscriptionUsageSnapshot( | |
| 112 | presentations: LimitPresentations, | |
| 113 | url: string, | |
| 114 | ): SubscriptionUsageSnapshot { | |
| 115 | // Freshness is evaluated at publication/render time, not on unrelated config emissions. | |
| 116 | return { ...subscriptionUsageProps(collectLimitAccounts(presentations), 0), url }; | |
| 117 | } | |
| 118 | ||
| 119 | export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) { | |
| 120 | const deadlines = [...new Set(snapshot.providers.map((p) => p.expiresAt))] | |
| 121 | .filter((deadline) => deadline > now) | |
| 122 | .sort((a, b) => a - b); | |
| 123 | return [now, ...deadlines].map((date) => ({ | |
| 124 | date: new Date(date), | |
| 125 | props: { | |
| 126 | ...snapshot, | |
| 127 | providers: snapshot.providers.map((provider) => | |
| 128 | provider.windows.length > 0 && provider.expiresAt <= date | |
| 129 | ? { ...provider, detail: "Open T3 to refresh", windows: [], totalWindows: 0 } | |
| 130 | : provider, | |
| 131 | ), | |
| 132 | }, | |
| 133 | })); | |
| 134 | } |
apps/mobile/src/widgets/useSubscriptionUsage.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { useEffect, useMemo } from "react"; | |
| 3 | import { AppState } from "react-native"; | |
| 4 | ||
| 5 | import { environmentPresentations } from "../state/presentation"; | |
| 6 | import { serverEnvironment } from "../state/server"; | |
| 7 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 8 | import { createWidgetRefresher, WIDGET_REFRESH_INTERVAL } from "./subscriptionUsageSnapshot"; | |
| 9 | ||
| 10 | export function useSubscriptionUsage(enabled = true) { | |
| 11 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 12 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | |
| 13 | reportFailure: false, | |
| 14 | }); | |
| 15 | const refresh = useMemo( | |
| 16 | () => | |
| 17 | createWidgetRefresher( | |
| 18 | (environmentId: Parameters<typeof refreshProviders>[0]["environmentId"]) => | |
| 19 | refreshProviders({ environmentId, input: {} }), | |
| 20 | ), | |
| 21 | [refreshProviders], | |
| 22 | ); | |
| 23 | ||
| 24 | useEffect(() => { | |
| 25 | const update = () => { | |
| 26 | if (!enabled || AppState.currentState !== "active") return; | |
| 27 | const connected = [...presentations] | |
| 28 | .filter(([, presentation]) => presentation.connection.phase === "connected") | |
| 29 | .map(([id]) => id); | |
| 30 | void refresh(connected, Date.now()); | |
| 31 | }; | |
| 32 | update(); | |
| 33 | const subscription = AppState.addEventListener("change", update); | |
| 34 | const timer = setInterval(update, WIDGET_REFRESH_INTERVAL); | |
| 35 | return () => { | |
| 36 | subscription.remove(); | |
| 37 | clearInterval(timer); | |
| 38 | }; | |
| 39 | }, [enabled, presentations, refresh]); | |
| 40 | } |
docs/user/usage.md+6| ... | ... | @@ -81,3 +81,9 @@ account and choose **Use reset** to redeem one. No hub plugin is required. |
| 81 | 81 | This connection supplies usage information; configure |
| 82 | 82 | the provider separately to send agent requests through the hub. Remove the hub from the same |
| 83 | 83 | settings section when you no longer need it. |
| 84 | ||
| 85 | ## Subscription usage widget | |
| 86 | ||
| 87 | Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and | |
| 88 | Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session, | |
| 89 | Weekly, or both for each provider. Reopen T3 to refresh expired readings. |
packages/shared/src/usageLimits.test.ts-188| ... | ... | @@ -3,7 +3,6 @@ import { |
| 3 | 3 | ProviderDriverKind, |
| 4 | 4 | ProviderInstanceId, |
| 5 | 5 | type ServerProvider, |
| 6 | type UsageLimitSourceAccount, | |
| 7 | 6 | UsageLimitSourceId, |
| 8 | 7 | } from "@t3tools/contracts"; |
| 9 | 8 | import { describe, expect, it } from "vite-plus/test"; |
| ... | ... | @@ -17,8 +16,6 @@ import { |
| 17 | 16 | collectLimitAccounts, |
| 18 | 17 | collectLimitNotices, |
| 19 | 18 | collectLimitPools, |
| 20 | collectLimitSources, | |
| 21 | collectLimitsGroups, | |
| 22 | 19 | elapsedShare, |
| 23 | 20 | formatResetsIn, |
| 24 | 21 | limitsNotice, |
| ... | ... | @@ -129,191 +126,6 @@ describe("providersWithLimits", () => { |
| 129 | 126 | }); |
| 130 | 127 | }); |
| 131 | 128 | |
| 132 | describe("collectLimitsGroups", () => { | |
| 133 | it("labels environments only when more than one reports limits", () => { | |
| 134 | const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; | |
| 135 | const codex = provider({ usageLimits: limits }); | |
| 136 | const one = new Map([ | |
| 137 | ["env-a", { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [codex] } }], | |
| 138 | [ | |
| 139 | "env-b", | |
| 140 | { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [provider({})] } }, | |
| 141 | ], | |
| 142 | ] as const); | |
| 143 | expect(collectLimitsGroups(one as never).map((group) => group.environmentLabel)).toEqual([ | |
| 144 | null, | |
| 145 | ]); | |
| 146 | ||
| 147 | const two = new Map([ | |
| 148 | ["env-a", { entry: { target: { label: "Laptop" } }, serverConfig: { providers: [codex] } }], | |
| 149 | ["env-b", { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [codex] } }], | |
| 150 | ] as const); | |
| 151 | expect(collectLimitsGroups(two as never).map((group) => group.environmentLabel)).toEqual([ | |
| 152 | "Laptop", | |
| 153 | "Desktop", | |
| 154 | ]); | |
| 155 | }); | |
| 156 | }); | |
| 157 | ||
| 158 | describe("collectLimitSources", () => { | |
| 159 | const source = { | |
| 160 | id: UsageLimitSourceId.make("cliproxy-hub"), | |
| 161 | kind: "cliproxy" as const, | |
| 162 | label: "hub", | |
| 163 | checkedAt: "2026-09-03T11:00:00.000Z", | |
| 164 | accounts: [], | |
| 165 | }; | |
| 166 | const limits = { checkedAt: source.checkedAt, windows: [window] }; | |
| 167 | const account: UsageLimitSourceAccount = { | |
| 168 | id: "codex-personal", | |
| 169 | driver: ProviderDriverKind.make("codex"), | |
| 170 | email: "person@example.com", | |
| 171 | plan: "ChatGPT Pro Subscription", | |
| 172 | usageLimits: limits, | |
| 173 | }; | |
| 174 | const native = provider({ | |
| 175 | displayName: "Personal", | |
| 176 | auth: { status: "authenticated", email: account.email }, | |
| 177 | usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, | |
| 178 | }); | |
| 179 | ||
| 180 | function presentations( | |
| 181 | providers: readonly ServerProvider[], | |
| 182 | accounts: readonly UsageLimitSourceAccount[] = [account], | |
| 183 | ) { | |
| 184 | return new Map([ | |
| 185 | [ | |
| 186 | EnvironmentId.make("env-a"), | |
| 187 | { | |
| 188 | entry: { target: { label: "Laptop" } }, | |
| 189 | serverConfig: { providers, usageLimitSources: [{ ...source, accounts }] }, | |
| 190 | }, | |
| 191 | ], | |
| 192 | ]); | |
| 193 | } | |
| 194 | ||
| 195 | it.each(["codex", "claudeAgent"])( | |
| 196 | "prefers native %s limits by email without changing provider rows or source snapshots", | |
| 197 | (kind) => { | |
| 198 | const driver = ProviderDriverKind.make(kind); | |
| 199 | const first = { ...native, driver }; | |
| 200 | const second = { ...first, instanceId: ProviderInstanceId.make("work") }; | |
| 201 | const accounts = [{ ...account, driver, email: " Person@Example.COM " }]; | |
| 202 | const input = presentations([first, second], accounts); | |
| 203 | ||
| 204 | expect(collectLimitSources(input)).toMatchObject([{ accounts: [], hiddenAccountCount: 1 }]); | |
| 205 | expect(collectLimitsGroups(input)[0]?.providers).toEqual([first, second]); | |
| 206 | expect(accounts).toHaveLength(1); | |
| 207 | expect(first.usageLimits?.resetCredits?.availableCount).toBe(2); | |
| 208 | }, | |
| 209 | ); | |
| 210 | ||
| 211 | it("matches across environments even when the hub is visited before the native provider", () => { | |
| 212 | const input = presentations([]); | |
| 213 | input.set(EnvironmentId.make("env-b"), { | |
| 214 | entry: { target: { label: "Desktop" } }, | |
| 215 | serverConfig: { providers: [native], usageLimitSources: [] }, | |
| 216 | }); | |
| 217 | ||
| 218 | expect(collectLimitSources(input)).toMatchObject([ | |
| 219 | { accounts: [], hiddenAccountCount: 1, environmentId: "env-a" }, | |
| 220 | ]); | |
| 221 | }); | |
| 222 | ||
| 223 | it("keeps other providers, other emails, and unidentified accounts with the same plan", () => { | |
| 224 | const accounts = [ | |
| 225 | account, | |
| 226 | { ...account, id: "other-provider", driver: ProviderDriverKind.make("claudeAgent") }, | |
| 227 | { ...account, id: "other-email", email: "other@example.com" }, | |
| 228 | { ...account, id: "unknown-email", email: undefined }, | |
| 229 | ]; | |
| 230 | ||
| 231 | expect(collectLimitSources(presentations([native], accounts))).toMatchObject([ | |
| 232 | { accounts: accounts.slice(1), hiddenAccountCount: 1 }, | |
| 233 | ]); | |
| 234 | expect( | |
| 235 | collectLimitSources( | |
| 236 | presentations([{ ...native, auth: { status: "authenticated" } }], accounts), | |
| 237 | )[0]?.accounts, | |
| 238 | ).toEqual(accounts); | |
| 239 | }); | |
| 240 | ||
| 241 | it.each([ | |
| 242 | { enabled: false }, | |
| 243 | { installed: false }, | |
| 244 | { availability: "unavailable" }, | |
| 245 | { usageLimits: undefined }, | |
| 246 | { usageLimits: { ...limits, windows: [] } }, | |
| 247 | { usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }, | |
| 248 | { usageLimits: { ...limits, unavailable: { reason: "unsupported" } } }, | |
| 249 | ] satisfies Partial<ServerProvider>[])( | |
| 250 | "retains hub limits when the native provider cannot show them: %j", | |
| 251 | (overrides) => { | |
| 252 | expect(collectLimitSources(presentations([{ ...native, ...overrides }]))).toMatchObject([ | |
| 253 | { accounts: [account], hiddenAccountCount: 0 }, | |
| 254 | ]); | |
| 255 | }, | |
| 256 | ); | |
| 257 | ||
| 258 | it("restores the hub account when the matching provider disappears", () => { | |
| 259 | const input = presentations([native]); | |
| 260 | expect(collectLimitSources(input)[0]?.accounts).toEqual([]); | |
| 261 | input.delete(EnvironmentId.make("env-a")); | |
| 262 | for (const [id, entry] of presentations([])) input.set(id, entry); | |
| 263 | ||
| 264 | expect(collectLimitSources(input)[0]?.accounts).toEqual([account]); | |
| 265 | }); | |
| 266 | ||
| 267 | it("keeps source errors and genuinely empty sources distinguishable from hidden accounts", () => { | |
| 268 | const input = new Map([ | |
| 269 | [ | |
| 270 | EnvironmentId.make("env-a"), | |
| 271 | { | |
| 272 | entry: { target: { label: "Laptop" } }, | |
| 273 | serverConfig: { | |
| 274 | providers: [native], | |
| 275 | usageLimitSources: [{ ...source, error: "Hub unavailable" }], | |
| 276 | }, | |
| 277 | }, | |
| 278 | ], | |
| 279 | ]); | |
| 280 | expect(collectLimitSources(input)).toMatchObject([ | |
| 281 | { accounts: [], hiddenAccountCount: 0, error: "Hub unavailable" }, | |
| 282 | ]); | |
| 283 | }); | |
| 284 | ||
| 285 | it("keys sources per environment and names the environment only when several have some", () => { | |
| 286 | const one = new Map([ | |
| 287 | [ | |
| 288 | "env-a", | |
| 289 | { entry: { target: { label: "Laptop" } }, serverConfig: { usageLimitSources: [source] } }, | |
| 290 | ], | |
| 291 | [ | |
| 292 | "env-b", | |
| 293 | { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [] } }, | |
| 294 | ], | |
| 295 | ] as const); | |
| 296 | expect(collectLimitSources(one as never).map((entry) => [entry.key, entry.label])).toEqual([ | |
| 297 | ["env-a:cliproxy-hub", "hub"], | |
| 298 | ]); | |
| 299 | ||
| 300 | const two = new Map([ | |
| 301 | [ | |
| 302 | "env-a", | |
| 303 | { entry: { target: { label: "Laptop" } }, serverConfig: { usageLimitSources: [source] } }, | |
| 304 | ], | |
| 305 | [ | |
| 306 | "env-b", | |
| 307 | { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [source] } }, | |
| 308 | ], | |
| 309 | ] as const); | |
| 310 | expect(collectLimitSources(two as never).map((entry) => entry.label)).toEqual([ | |
| 311 | "Laptop · hub", | |
| 312 | "Desktop · hub", | |
| 313 | ]); | |
| 314 | }); | |
| 315 | }); | |
| 316 | ||
| 317 | 129 | describe("pools", () => { |
| 318 | 130 | const checkedAt = "2026-09-03T11:00:00.000Z"; |
| 319 | 131 | const weekly = { |
packages/shared/src/usageLimits.ts+11-108| ... | ... | @@ -15,7 +15,6 @@ import { |
| 15 | 15 | type ServerProvider, |
| 16 | 16 | type ServerProviderUsageLimits, |
| 17 | 17 | type ServerProviderUsageWindow, |
| 18 | type UsageLimitSourceSnapshot, | |
| 19 | 18 | type UsageLimitSourceSnapshots, |
| 20 | 19 | } from "@t3tools/contracts"; |
| 21 | 20 | |
| ... | ... | @@ -42,108 +41,16 @@ export function providersWithLimits( |
| 42 | 41 | ); |
| 43 | 42 | } |
| 44 | 43 | |
| 45 | export interface LimitsGroup { | |
| 46 | readonly environmentId: EnvironmentId; | |
| 47 | /** Null while only one environment is connected; there is nothing to tell apart. */ | |
| 48 | readonly environmentLabel: string | null; | |
| 49 | readonly providers: readonly ServerProvider[]; | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * One group per connected environment with a provider reporting limits. | |
| 54 | * Provider snapshots come from the config stream every client already holds, | |
| 55 | * so opening the view costs no extra request. | |
| 56 | */ | |
| 57 | export function collectLimitsGroups( | |
| 58 | presentations: ReadonlyMap< | |
| 59 | EnvironmentId, | |
| 60 | { | |
| 61 | readonly entry: { readonly target: { readonly label: string } }; | |
| 62 | readonly serverConfig: { | |
| 63 | readonly providers?: readonly ServerProvider[] | undefined; | |
| 64 | } | null; | |
| 65 | } | |
| 66 | >, | |
| 67 | ): readonly LimitsGroup[] { | |
| 68 | const groups: LimitsGroup[] = []; | |
| 69 | for (const [environmentId, presentation] of presentations) { | |
| 70 | const providers = providersWithLimits(presentation.serverConfig?.providers ?? []); | |
| 71 | if (providers.length === 0) continue; | |
| 72 | groups.push({ environmentId, environmentLabel: presentation.entry.target.label, providers }); | |
| 73 | } | |
| 74 | return groups.length > 1 ? groups : groups.map((group) => ({ ...group, environmentLabel: null })); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Every usage-limit source across connected environments, keyed so two | |
| 79 | * environments pointing at the same hub still get their own rows. The label | |
| 80 | * carries the environment only when more than one environment has sources. | |
| 81 | * A native provider with usable limits takes precedence over the same account | |
| 82 | * in a source, even when it belongs to another connected environment. | |
| 83 | */ | |
| 84 | export function collectLimitSources( | |
| 85 | presentations: ReadonlyMap< | |
| 86 | EnvironmentId, | |
| 87 | { | |
| 88 | readonly entry: { readonly target: { readonly label: string } }; | |
| 89 | readonly serverConfig: { | |
| 90 | readonly providers?: readonly ServerProvider[] | undefined; | |
| 91 | readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; | |
| 92 | } | null; | |
| 93 | } | |
| 94 | >, | |
| 95 | ): ReadonlyArray< | |
| 96 | UsageLimitSourceSnapshot & { | |
| 97 | readonly key: string; | |
| 98 | readonly environmentId: EnvironmentId; | |
| 99 | readonly hiddenAccountCount: number; | |
| 44 | export type LimitPresentations = ReadonlyMap< | |
| 45 | EnvironmentId, | |
| 46 | { | |
| 47 | readonly entry: { readonly target: { readonly label: string } }; | |
| 48 | readonly serverConfig: { | |
| 49 | readonly providers?: readonly ServerProvider[] | undefined; | |
| 50 | readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; | |
| 51 | } | null; | |
| 100 | 52 | } |
| 101 | > { | |
| 102 | const nativeAccounts = new Set<string>(); | |
| 103 | for (const presentation of presentations.values()) { | |
| 104 | for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { | |
| 105 | const key = accountKey(provider.driver, provider.auth.email); | |
| 106 | if ( | |
| 107 | key !== null && | |
| 108 | provider.usageLimits?.windows.length && | |
| 109 | !provider.usageLimits.unavailable | |
| 110 | ) { | |
| 111 | nativeAccounts.add(key); | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 115 | const perEnvironment: Array<{ | |
| 116 | readonly environmentId: EnvironmentId; | |
| 117 | readonly environmentLabel: string; | |
| 118 | readonly sources: UsageLimitSourceSnapshots; | |
| 119 | }> = []; | |
| 120 | for (const [environmentId, presentation] of presentations) { | |
| 121 | const sources = presentation.serverConfig?.usageLimitSources ?? []; | |
| 122 | if (sources.length === 0) continue; | |
| 123 | perEnvironment.push({ | |
| 124 | environmentId, | |
| 125 | environmentLabel: presentation.entry.target.label, | |
| 126 | sources, | |
| 127 | }); | |
| 128 | } | |
| 129 | const labelEnvironment = perEnvironment.length > 1; | |
| 130 | return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) => | |
| 131 | sources.map((source) => { | |
| 132 | const accounts = source.accounts.filter((account) => { | |
| 133 | const key = accountKey(account.driver, account.email); | |
| 134 | return key === null || !nativeAccounts.has(key); | |
| 135 | }); | |
| 136 | return { | |
| 137 | ...source, | |
| 138 | accounts, | |
| 139 | hiddenAccountCount: source.accounts.length - accounts.length, | |
| 140 | environmentId, | |
| 141 | key: `${environmentId}:${source.id}`, | |
| 142 | label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label, | |
| 143 | }; | |
| 144 | }), | |
| 145 | ); | |
| 146 | } | |
| 53 | >; | |
| 147 | 54 | |
| 148 | 55 | function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { |
| 149 | 56 | const normalizedEmail = email?.trim().toLowerCase(); |
| ... | ... | @@ -184,9 +91,7 @@ export interface LimitAccount { |
| 184 | 91 | * entry per distinct account. The freshest reads supply windows and credits; |
| 185 | 92 | * native instances supply names and environment labels. |
| 186 | 93 | */ |
| 187 | export function collectLimitAccounts( | |
| 188 | presentations: Parameters<typeof collectLimitSources>[0], | |
| 189 | ): readonly LimitAccount[] { | |
| 94 | export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] { | |
| 190 | 95 | const accounts = new Map<string, LimitAccount>(); |
| 191 | 96 | const creditSources = new Map<string, LimitAccount>(); |
| 192 | 97 | const hubRedeems = new Map<string, LimitAccount>(); |
| ... | ... | @@ -315,9 +220,7 @@ export function collectLimitAccounts( |
| 315 | 220 | * are left out; there is nothing for the user to act on. The environment |
| 316 | 221 | * is named only when more than one is connected. |
| 317 | 222 | */ |
| 318 | export function collectLimitNotices( | |
| 319 | presentations: Parameters<typeof collectLimitSources>[0], | |
| 320 | ): readonly string[] { | |
| 223 | export function collectLimitNotices(presentations: LimitPresentations): readonly string[] { | |
| 321 | 224 | const label = (environmentLabel: string, subject: string) => |
| 322 | 225 | presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; |
| 323 | 226 | const notices: string[] = []; |