| author | |
| committer | |
| log | 59abcd67ac2c54bd63a422e92fa2e31b2c667b6d |
| tree | a461ca2c4643bac7b015ff8bfb98b54863b82b32 |
| parent | 72447f23d9d9102f647f95f548aadf92edd5b400 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>11 files changed, 241 insertions(+), 115 deletions(-)
apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
| 2 | 2 | <application> |
| 3 | <receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:exported="false" android:label="@string/t3_subscription_widget_name"> | |
| 3 | <receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:enabled="@bool/t3_subscription_widget_enabled" android:exported="false" android:label="@string/t3_subscription_widget_name"> | |
| 4 | 4 | <intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter> |
| 5 | 5 | <meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" /> |
| 6 | 6 | </receiver> |
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt+53-42| ... | ... | @@ -8,7 +8,7 @@ import android.content.ComponentName |
| 8 | 8 | import android.content.Context |
| 9 | 9 | import android.content.Intent |
| 10 | 10 | import android.net.Uri |
| 11 | import android.os.Bundle | |
| 11 | import android.os.Build | |
| 12 | 12 | import android.view.View |
| 13 | 13 | import android.widget.RemoteViews |
| 14 | 14 | import org.json.JSONObject |
| ... | ... | @@ -29,15 +29,6 @@ class SubscriptionUsageWidget : AppWidgetProvider() { |
| 29 | 29 | context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) |
| 30 | 30 | } |
| 31 | 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 | 32 | companion object { |
| 42 | 33 | const val PREFERENCES = "t3_subscription_widget" |
| 43 | 34 | private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE" |
| ... | ... | @@ -56,59 +47,68 @@ class SubscriptionUsageWidget : AppWidgetProvider() { |
| 56 | 47 | } |
| 57 | 48 | |
| 58 | 49 | private fun update(context: Context, manager: AppWidgetManager, id: Int) { |
| 50 | // The receiver is disabled below 12L (values-v32/bools.xml), but the module still calls in. | |
| 51 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S_V2) return | |
| 59 | 52 | val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) |
| 60 | 53 | 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 | } | |
| 54 | val openApp = openAppIntent(context, id, snapshot) | |
| 65 | 55 | val providers = snapshot?.optJSONArray("providers") |
| 66 | 56 | val now = System.currentTimeMillis() |
| 67 | 57 | var nextExpiry = Long.MAX_VALUE |
| 68 | var totalRows = 0 | |
| 69 | 58 | val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index -> |
| 70 | 59 | val provider = providers?.optJSONObject(index) ?: return@mapNotNull null |
| 71 | 60 | val windows = provider.optJSONArray("windows") |
| 72 | 61 | val expiresAt = provider.optLong("expiresAt") |
| 73 | 62 | if (expiresAt > now && windows != null && windows.length() > 0) { |
| 74 | 63 | nextExpiry = minOf(nextExpiry, expiresAt) |
| 75 | totalRows += provider.optInt("totalWindows", windows.length()) | |
| 76 | 64 | (0 until windows.length()).map { provider to windows.optJSONObject(it) } |
| 77 | 65 | } else { |
| 78 | totalRows++ | |
| 79 | 66 | listOf(provider to null) |
| 80 | 67 | } |
| 81 | 68 | } |
| 82 | // Show each provider before filling spare space with its other windows. | |
| 69 | // Keep the first quota from each provider near the top of the list. | |
| 83 | 70 | val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index -> |
| 84 | 71 | groups.mapNotNull { it.getOrNull(index) } |
| 85 | 72 | } |
| 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 | |
| 73 | val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) | |
| 74 | // Count limits only; "Open app to refresh" placeholders are not entries. | |
| 75 | val limits = rows.count { (_, window) -> window != null } | |
| 76 | // Without limits the layout's plain title stays. | |
| 77 | if (limits > 0) { | |
| 78 | views.setTextViewText( | |
| 79 | R.id.t3_widget_title, | |
| 80 | context.getString(R.string.t3_subscription_widget_title_count, limits) | |
| 81 | ) | |
| 82 | views.setContentDescription( | |
| 83 | R.id.t3_widget_title, | |
| 84 | context.resources.getQuantityString( | |
| 85 | R.plurals.t3_subscription_widget_title_description, | |
| 86 | limits, | |
| 87 | limits | |
| 88 | ) | |
| 89 | ) | |
| 90 | } | |
| 91 | openApp?.let { views.setOnClickPendingIntent(R.id.t3_widget_root, it) } | |
| 92 | openAppIntent(context, id, snapshot, forCollection = true)?.let { | |
| 93 | views.setPendingIntentTemplate(R.id.t3_widget_rows, it) | |
| 94 | } | |
| 95 | val items = RemoteViews.RemoteCollectionItems.Builder() | |
| 96 | rows.forEachIndexed { index, (provider, window) -> | |
| 97 | items.addItem(index.toLong(), rowView(context, provider, window)) | |
| 98 | } | |
| 99 | views.setRemoteAdapter(R.id.t3_widget_rows, items.build()) | |
| 100 | views.setEmptyView(R.id.t3_widget_rows, R.id.t3_widget_empty) | |
| 101 | val checkedAt = snapshot?.optLong("checkedAt") ?: 0 | |
| 102 | val checked = if (checkedAt > 0) { | |
| 96 | 103 | val formatted = DateFormat.getDateTimeInstance( |
| 97 | 104 | DateFormat.SHORT, |
| 98 | 105 | DateFormat.SHORT |
| 99 | 106 | ).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) | |
| 107 | context.getString(R.string.t3_subscription_widget_last_checked, formatted) | |
| 108 | } else { | |
| 109 | context.getString(R.string.t3_subscription_widget_unknown_check) | |
| 111 | 110 | } |
| 111 | views.setTextViewText(R.id.t3_widget_footer, checked) | |
| 112 | 112 | val alarms = context.getSystemService(AlarmManager::class.java) |
| 113 | 113 | alarms.cancel(expiryIntent(context)) |
| 114 | 114 | // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry. |
| ... | ... | @@ -118,7 +118,12 @@ class SubscriptionUsageWidget : AppWidgetProvider() { |
| 118 | 118 | manager.updateAppWidget(id, views) |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? { | |
| 121 | private fun openAppIntent( | |
| 122 | context: Context, | |
| 123 | id: Int, | |
| 124 | snapshot: JSONObject?, | |
| 125 | forCollection: Boolean = false | |
| 126 | ): PendingIntent? { | |
| 122 | 127 | // Target this variant's launcher so co-installed builds cannot steal the tap. |
| 123 | 128 | val intent = |
| 124 | 129 | context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null |
| ... | ... | @@ -129,9 +134,14 @@ class SubscriptionUsageWidget : AppWidgetProvider() { |
| 129 | 134 | intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP |
| 130 | 135 | return PendingIntent.getActivity( |
| 131 | 136 | context, |
| 132 | id, | |
| 137 | id * 2 + if (forCollection) 1 else 0, | |
| 133 | 138 | intent, |
| 134 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | |
| 139 | PendingIntent.FLAG_UPDATE_CURRENT or if (forCollection) { | |
| 140 | // Collection rows use fill-in intents with an explicit app target. | |
| 141 | PendingIntent.FLAG_MUTABLE | |
| 142 | } else { | |
| 143 | PendingIntent.FLAG_IMMUTABLE | |
| 144 | } | |
| 135 | 145 | ) |
| 136 | 146 | } |
| 137 | 147 | |
| ... | ... | @@ -153,6 +163,7 @@ class SubscriptionUsageWidget : AppWidgetProvider() { |
| 153 | 163 | val reset = window?.optString("reset") |
| 154 | 164 | ?: context.getString(R.string.t3_subscription_widget_refresh) |
| 155 | 165 | child.setTextViewText(R.id.t3_widget_reset, reset) |
| 166 | child.setOnClickFillInIntent(R.id.t3_widget_row, Intent()) | |
| 156 | 167 | child.setContentDescription( |
| 157 | 168 | R.id.t3_widget_row, |
| 158 | 169 | "$label. $windowLabel. $percent. $reset. $detail" |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml+5-4| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
| 2 | 2 | android:id="@+id/t3_widget_root" android:layout_width="match_parent" android:layout_height="match_parent" |
| 3 | 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> | |
| 4 | <TextView android:id="@+id/t3_widget_title" 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 | <FrameLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"> | |
| 6 | <ListView android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="match_parent" android:divider="@null" android:scrollbars="vertical" /> | |
| 7 | <TextView android:id="@+id/t3_widget_empty" 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" /> | |
| 8 | </FrameLayout> | |
| 8 | 9 | <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 | 10 | </LinearLayout> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-v32/bools.xml created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <resources> | |
| 2 | <!-- Android 12L is the first release whose collection widgets need no compat service. --> | |
| 3 | <bool name="t3_subscription_widget_enabled">true</bool> | |
| 4 | </resources> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/bools.xml created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | <resources> | |
| 2 | <bool name="t3_subscription_widget_enabled">false</bool> | |
| 3 | </resources> |
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml+7-3| ... | ... | @@ -1,11 +1,15 @@ |
| 1 | 1 | <resources> |
| 2 | 2 | <string name="t3_subscription_widget_unknown_check">Last checked unavailable</string> |
| 3 | 3 | <string name="t3_subscription_widget_name">Subscription usage</string> |
| 4 | <string name="t3_subscription_widget_title_count">Subscription usage (%1$d)</string> | |
| 5 | <plurals name="t3_subscription_widget_title_description"> | |
| 6 | <item quantity="one">Subscription usage, %1$d entry</item> | |
| 7 | <item quantity="other">Subscription usage, %1$d entries</item> | |
| 8 | </plurals> | |
| 4 | 9 | <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> | |
| 10 | <string name="t3_subscription_widget_empty">No subscription limits available. Open T3 Code to connect.</string> | |
| 6 | 11 | <string name="t3_subscription_widget_open">Tap to open Usage</string> |
| 7 | 12 | <string name="t3_subscription_widget_refresh">Open app to refresh</string> |
| 8 | 13 | <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> | |
| 14 | <string name="t3_subscription_widget_last_checked">Last checked %1$s</string> | |
| 11 | 15 | </resources> |
apps/mobile/src/widgets/SubscriptionUsage.tsx+5-1| ... | ... | @@ -235,7 +235,11 @@ function SubscriptionUsage( |
| 235 | 235 | spacing={accessory || dense ? 2 : 6} |
| 236 | 236 | modifiers={props.url ? [widgetURL(props.url)] : []} |
| 237 | 237 | > |
| 238 | {compact ? ( | |
| 238 | {providers.length === 0 ? ( | |
| 239 | <Text modifiers={[font({ textStyle: "caption" }), foregroundStyle("secondary")]}> | |
| 240 | No subscription limits available. | |
| 241 | </Text> | |
| 242 | ) : compact ? ( | |
| 239 | 243 | <VStack alignment="leading" spacing={accessory || dense ? 4 : 8}> |
| 240 | 244 | {columns} |
| 241 | 245 | </VStack> |
apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx+3| ... | ... | @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 3 | import * as Linking from "expo-linking"; |
| 4 | 4 | import { useEffect } from "react"; |
| 5 | import { Platform } from "react-native"; | |
| 5 | 6 | import { environmentCatalog } from "../connection/catalog"; |
| 6 | 7 | import { environmentPresentations } from "../state/presentation"; |
| 7 | 8 | import { publishSubscriptionUsage } from "./publishSubscriptionUsage"; |
| ... | ... | @@ -13,6 +14,8 @@ const snapshotAtom = Atom.make((get) => |
| 13 | 14 | buildSubscriptionUsageSnapshot( |
| 14 | 15 | get(environmentPresentations.presentationsAtom), |
| 15 | 16 | Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }), |
| 17 | // Android scrolls the full list; iOS stores a bounded widget timeline. | |
| 18 | Platform.OS === "android" ? Infinity : 6, | |
| 16 | 19 | ), |
| 17 | 20 | ).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); |
| 18 | 21 |
apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts+71-11| ... | ... | @@ -65,18 +65,63 @@ describe("subscription widget snapshots", () => { |
| 65 | 65 | expect(snapshot.url).toBe(deepLink); |
| 66 | 66 | expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); |
| 67 | 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); | |
| 68 | it("clears data after removing environments", () => { | |
| 69 | expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).providers).toEqual([]); | |
| 70 | }); | |
| 71 | it.each<{ name: string; overrides: Partial<ServerProvider> }>([ | |
| 72 | { name: "disabled", overrides: { enabled: false } }, | |
| 73 | { | |
| 74 | name: "missing", | |
| 75 | overrides: { installed: false, status: "error", usageLimits: undefined }, | |
| 76 | }, | |
| 77 | { | |
| 78 | name: "API-key", | |
| 79 | overrides: { | |
| 80 | usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, | |
| 81 | }, | |
| 82 | }, | |
| 83 | ])("hides $name providers", ({ overrides }) => { | |
| 74 | 84 | expect( |
| 75 | buildSubscriptionUsageSnapshot( | |
| 76 | presentations([provider({ enabled: false })]), | |
| 77 | deepLink, | |
| 78 | ).providers.every((p) => p.windows.length === 0), | |
| 79 | ).toBe(true); | |
| 85 | buildSubscriptionUsageSnapshot(presentations([provider(overrides)]), deepLink).providers, | |
| 86 | ).toEqual([]); | |
| 87 | }); | |
| 88 | it.each([ | |
| 89 | { name: "Codex", driver: "codex" }, | |
| 90 | { name: "Claude", driver: "claudeAgent" }, | |
| 91 | ])("only shows $name when $name and OpenCode are configured", ({ name, driver }) => { | |
| 92 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 93 | presentations([ | |
| 94 | provider({ | |
| 95 | instanceId: ProviderInstanceId.make(driver), | |
| 96 | driver: ProviderDriverKind.make(driver), | |
| 97 | }), | |
| 98 | provider({ | |
| 99 | instanceId: ProviderInstanceId.make("opencode"), | |
| 100 | driver: ProviderDriverKind.make("opencode"), | |
| 101 | usageLimits: undefined, | |
| 102 | }), | |
| 103 | ]), | |
| 104 | deepLink, | |
| 105 | ); | |
| 106 | expect(snapshot.providers).toHaveLength(1); | |
| 107 | expect(snapshot.providers[0]).toMatchObject({ | |
| 108 | name, | |
| 109 | totalWindows: 1, | |
| 110 | windows: [{ remaining: 60 }], | |
| 111 | }); | |
| 112 | expect(subscriptionUsageTimeline(snapshot, now + 15 * 60_000)[0]?.props.providers).toEqual([ | |
| 113 | expect.objectContaining({ name, totalWindows: 0, windows: [] }), | |
| 114 | ]); | |
| 115 | }); | |
| 116 | it("keeps an enabled provider visible before its first usage read", () => { | |
| 117 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 118 | presentations([provider({ usageLimits: undefined })]), | |
| 119 | deepLink, | |
| 120 | ); | |
| 121 | expect(snapshot.checkedAt).toBe(0); | |
| 122 | expect(snapshot.providers).toEqual([ | |
| 123 | { name: "Codex", detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 }, | |
| 124 | ]); | |
| 80 | 125 | }); |
| 81 | 126 | it("uses upstream deduplication for a native account also present in a proxy hub", () => { |
| 82 | 127 | const input = new Map([ |
| ... | ... | @@ -151,6 +196,21 @@ describe("subscription widget snapshots", () => { |
| 151 | 196 | expect(snapshot.providers[0]?.totalWindows).toBe(20); |
| 152 | 197 | expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5); |
| 153 | 198 | }); |
| 199 | it("includes every limit for the scrollable Android widget", () => { | |
| 200 | const windows = Array.from({ length: 20 }, (_, index) => ({ | |
| 201 | ...window, | |
| 202 | id: `${index}`, | |
| 203 | usedPercent: index * 5, | |
| 204 | })); | |
| 205 | const snapshot = buildSubscriptionUsageSnapshot( | |
| 206 | presentations([provider({ usageLimits: { checkedAt, windows } })]), | |
| 207 | deepLink, | |
| 208 | Infinity, | |
| 209 | ); | |
| 210 | expect(snapshot.providers[0]?.windows.map((window) => window.remaining)).toEqual( | |
| 211 | Array.from({ length: 20 }, (_, index) => 5 + index * 5), | |
| 212 | ); | |
| 213 | }); | |
| 154 | 214 | it("marks unknown or distant reset times stale after fifteen minutes", () => { |
| 155 | 215 | const snapshot = buildSubscriptionUsageSnapshot( |
| 156 | 216 | presentations([ |
apps/mobile/src/widgets/subscriptionUsageSnapshot.ts+87-52| ... | ... | @@ -46,6 +46,8 @@ export function createWidgetRefresher<Id>(refresh: (id: Id) => Promise<unknown>) |
| 46 | 46 | function subscriptionUsageProps( |
| 47 | 47 | accounts: readonly LimitAccount[], |
| 48 | 48 | now: number, |
| 49 | configuredDrivers: ReadonlySet<string>, | |
| 50 | maxWindowsPerProvider: number, | |
| 49 | 51 | ): SubscriptionUsageSnapshot { |
| 50 | 52 | const pools = collectLimitPools(accounts, now); |
| 51 | 53 | const checked = accounts |
| ... | ... | @@ -53,57 +55,68 @@ function subscriptionUsageProps( |
| 53 | 55 | .map((account) => Date.parse(account.limits.checkedAt)); |
| 54 | 56 | return { |
| 55 | 57 | 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 | }), | |
| 58 | providers: (["codex", "claudeAgent"] as const) | |
| 59 | .filter( | |
| 60 | (driver) => | |
| 61 | configuredDrivers.has(driver) || accounts.some((account) => account.driver === driver), | |
| 62 | ) | |
| 63 | .map((driver) => { | |
| 64 | const pool = pools.find((candidate) => candidate.driver === driver); | |
| 65 | const name = driver === "codex" ? "Codex" : "Claude"; | |
| 66 | if (!pool) | |
| 67 | return { | |
| 68 | name, | |
| 69 | detail: "No limits available", | |
| 70 | windows: [], | |
| 71 | expiresAt: 0, | |
| 72 | totalWindows: 0, | |
| 73 | }; | |
| 74 | const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); | |
| 75 | const expiresAt = Math.min( | |
| 76 | checkedAt + SNAPSHOT_MAX_AGE, | |
| 77 | ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), | |
| 78 | ); | |
| 79 | const fresh = Number.isFinite(expiresAt) && expiresAt > now; | |
| 80 | const sortedWindows = [...pool.windows].sort( | |
| 81 | (a, b) => a.remainingPercent - b.remainingPercent, | |
| 82 | ); | |
| 83 | // Keep a session and weekly limit when scoped limits fill the storage budget. | |
| 84 | const selectedWindows = [ | |
| 85 | ...new Set([ | |
| 86 | sortedWindows.find((window) => window.kind === "session"), | |
| 87 | sortedWindows.find((window) => window.kind === "weekly"), | |
| 88 | ...sortedWindows, | |
| 89 | ]), | |
| 90 | ] | |
| 91 | .filter((window) => window !== undefined) | |
| 92 | .slice(0, maxWindowsPerProvider) | |
| 93 | .sort((a, b) => a.remainingPercent - b.remainingPercent); | |
| 94 | return { | |
| 95 | name, | |
| 96 | detail: !fresh | |
| 97 | ? "Open T3 to refresh" | |
| 98 | : pool.accounts.length > 1 | |
| 99 | ? `${pool.accounts.length} accounts · pooled` | |
| 100 | : "Subscription remaining", | |
| 101 | expiresAt: fresh ? expiresAt : 0, | |
| 102 | totalWindows: fresh ? pool.windows.length : 0, | |
| 103 | windows: fresh | |
| 104 | ? selectedWindows.map((window) => ({ | |
| 105 | kind: window.kind, | |
| 106 | label: window.label, | |
| 107 | remaining: Math.round(window.remainingPercent), | |
| 108 | reset: window.resets[0] | |
| 109 | ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { | |
| 110 | month: "short", | |
| 111 | day: "numeric", | |
| 112 | hour: "numeric", | |
| 113 | minute: "2-digit", | |
| 114 | })}` | |
| 115 | : "Reset time unavailable", | |
| 116 | })) | |
| 117 | : [], | |
| 118 | }; | |
| 119 | }), | |
| 107 | 120 | }; |
| 108 | 121 | } |
| 109 | 122 | |
| ... | ... | @@ -111,9 +124,31 @@ function subscriptionUsageProps( |
| 111 | 124 | export function buildSubscriptionUsageSnapshot( |
| 112 | 125 | presentations: LimitPresentations, |
| 113 | 126 | url: string, |
| 127 | maxWindowsPerProvider = 6, | |
| 114 | 128 | ): SubscriptionUsageSnapshot { |
| 115 | 129 | // Freshness is evaluated at publication/render time, not on unrelated config emissions. |
| 116 | return { ...subscriptionUsageProps(collectLimitAccounts(presentations), 0), url }; | |
| 130 | const configuredDrivers = new Set( | |
| 131 | [...presentations.values()].flatMap((presentation) => | |
| 132 | (presentation.serverConfig?.providers ?? []) | |
| 133 | // Servers report default-enabled drivers even when their CLI is missing. | |
| 134 | .filter( | |
| 135 | (provider) => | |
| 136 | provider.enabled && | |
| 137 | provider.installed && | |
| 138 | provider.usageLimits?.unavailable?.reason !== "unsupported", | |
| 139 | ) | |
| 140 | .map((provider) => provider.driver), | |
| 141 | ), | |
| 142 | ); | |
| 143 | return { | |
| 144 | ...subscriptionUsageProps( | |
| 145 | collectLimitAccounts(presentations), | |
| 146 | 0, | |
| 147 | configuredDrivers, | |
| 148 | maxWindowsPerProvider, | |
| 149 | ), | |
| 150 | url, | |
| 151 | }; | |
| 117 | 152 | } |
| 118 | 153 | |
| 119 | 154 | export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) { |
docs/user/usage.md+2-1| ... | ... | @@ -102,4 +102,5 @@ settings section when you no longer need it. |
| 102 | 102 | |
| 103 | 103 | Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and |
| 104 | 104 | Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session, |
| 105 | Weekly, or both for each provider. Reopen T3 to refresh expired readings. | |
| 105 | Weekly, or both for each provider. Reopen T3 to refresh expired readings. The Android widget | |
| 106 | requires Android 12L or later. |