diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c5ed21c0..0ba2c8a134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree! ### Fixed +- Sakana AI: parse server-rendered quota reset timestamps as UTC instead of device-local time (#1826). Thanks @ss251! - Cursor: hide misleading pace and run-out details once a billing-cycle quota is fully depleted. Thanks @Yuxin-Qiao! - Claude Education: treat subscription-only CLI responses as unavailable quotas, keep local cost data in menus and widgets, and suppress expected refresh cancellations (#1808). - Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely. diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift index 360dd842f2..0e94eb6aed 100644 --- a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift @@ -137,11 +137,10 @@ public enum SakanaUsageFetcher { static func parseBillingHTML( _ html: String, - now: Date = Date(), - timeZone: TimeZone = .current) throws -> SakanaUsageSnapshot + now: Date = Date()) throws -> SakanaUsageSnapshot { - let fiveHour = try self.parseWindow(label: "5-hour", html: html, timeZone: timeZone) - let weekly = try self.parseWindow(label: "Weekly", html: html, timeZone: timeZone) + let fiveHour = try self.parseWindow(label: "5-hour", html: html) + let weekly = try self.parseWindow(label: "Weekly", html: html) guard fiveHour != nil || weekly != nil else { throw SakanaUsageError.parseFailed("Usage limit windows were not found.") } @@ -155,8 +154,7 @@ public enum SakanaUsageFetcher { private static func parseWindow( label: String, - html: String, - timeZone: TimeZone) throws -> SakanaUsageSnapshot.QuotaWindow? + html: String) throws -> SakanaUsageSnapshot.QuotaWindow? { guard let windowBody = self.windowBody(label: label, html: html) else { return nil } guard let percentText = self.capture( @@ -173,7 +171,7 @@ public enum SakanaUsageFetcher { in: windowBody) return SakanaUsageSnapshot.QuotaWindow( usedPercent: percent, - resetsAt: resetText.flatMap { self.parseResetDate($0, timeZone: timeZone) }) + resetsAt: resetText.flatMap(self.parseResetDate)) } private static func windowBody(label: String, html: String) -> String? { @@ -218,11 +216,15 @@ public enum SakanaUsageFetcher { in: html) } - private static func parseResetDate(_ value: String, timeZone: TimeZone) -> Date? { + /// The billing page always server-renders "Resets on " in UTC — the client only + /// corrects it to the viewer's local timezone after JS hydration, which this HTML-only + /// scraper never runs. Parsing with any other timezone silently shifts every reset by the + /// device's UTC offset (see steipete/CodexBar#1826). + private static func parseResetDate(_ value: String) -> Date? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = timeZone + formatter.timeZone = TimeZone(identifier: "UTC") formatter.dateFormat = "MMMM d, yyyy 'at' h:mm a" return formatter.date(from: trimmed) } diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift index 58af46663c..25d76a1364 100644 --- a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift @@ -5,22 +5,22 @@ import FoundationNetworking import Testing @testable import CodexBarCore +@Suite(.serialized) struct SakanaUsageFetcherTests { @Test func `billing html maps five hour and weekly windows`() throws { let now = Date(timeIntervalSince1970: 1_782_222_000) let usage = try SakanaUsageFetcher.parseBillingHTML( Self.billingHTML, - now: now, - timeZone: Self.shanghaiTimeZone).toUsageSnapshot() + now: now).toUsageSnapshot() #expect(usage.primary?.usedPercent == 92) #expect(usage.primary?.windowMinutes == 300) - #expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 22, minute: 53)) + #expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 14, minute: 53)) #expect(usage.primary?.resetDescription == nil) #expect(usage.secondary?.usedPercent == 32) #expect(usage.secondary?.windowMinutes == 10080) - #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 8, minute: 0)) + #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 0, minute: 0)) #expect(usage.secondary?.resetDescription == nil) #expect(usage.identity?.providerID == .sakana) #expect(usage.identity?.loginMethod == "Standard $20/mo") @@ -113,8 +113,7 @@ struct SakanaUsageFetcherTests { @Test func `unparsed reset date does not become reset description`() throws { let usage = try SakanaUsageFetcher.parseBillingHTML( - Self.billingHTML.replacing("June 23, 2026 at 10:53 PM", with: "soon-ish"), - timeZone: Self.shanghaiTimeZone).toUsageSnapshot() + Self.billingHTML.replacing("June 23, 2026 at 2:53 PM", with: "soon-ish")).toUsageSnapshot() #expect(usage.primary?.usedPercent == 92) #expect(usage.primary?.resetsAt == nil) @@ -124,18 +123,16 @@ struct SakanaUsageFetcherTests { @Test func `window without reset line still maps percent`() throws { let html = Self.billingHTML.replacing( - "

Resets on June 23, 2026 at 10:53 PM

", + "

Resets on June 23, 2026 at 2:53 PM

", with: "") - let usage = try SakanaUsageFetcher.parseBillingHTML( - html, - timeZone: Self.shanghaiTimeZone).toUsageSnapshot() + let usage = try SakanaUsageFetcher.parseBillingHTML(html).toUsageSnapshot() #expect(usage.primary?.usedPercent == 92) #expect(usage.primary?.windowMinutes == 300) #expect(usage.primary?.resetsAt == nil) #expect(usage.primary?.resetDescription == nil) #expect(usage.secondary?.usedPercent == 32) - #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 8, minute: 0)) + #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 0, minute: 0)) } @Test @@ -145,15 +142,32 @@ struct SakanaUsageFetcherTests { with: "") #expect(throws: SakanaUsageError.parseFailed("Invalid 5-hour usage percentage.")) { - _ = try SakanaUsageFetcher.parseBillingHTML(html, timeZone: Self.shanghaiTimeZone) + _ = try SakanaUsageFetcher.parseBillingHTML(html) } } - private static let shanghaiTimeZone = TimeZone(identifier: "Asia/Shanghai")! + @Test + func `reset date is parsed as UTC regardless of the device's local timezone`() throws { + // The console always server-renders "Resets on " in UTC (the client corrects it to + // the viewer's local time only after JS hydration, which this HTML-only fetcher never + // runs). Regression coverage for steipete/CodexBar#1826: force the process default far + // from UTC (UTC+14) so this fails if TimeZone.current ever leaks back into the parser -- + // on a UTC CI runner the pre-fix TimeZone.current code would coincidentally still produce + // the right answer, so this test would not have caught the original bug without the + // override. + let originalTimeZone = NSTimeZone.default + NSTimeZone.default = TimeZone(secondsFromGMT: 14 * 60 * 60)! + defer { NSTimeZone.default = originalTimeZone } + + let usage = try SakanaUsageFetcher.parseBillingHTML(Self.billingHTML).toUsageSnapshot() + + #expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 14, minute: 53)) + #expect(usage.primary?.resetsAt?.timeIntervalSince1970 == 1_782_226_380) + } private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = Self.shanghaiTimeZone + calendar.timeZone = TimeZone(identifier: "UTC")! return calendar.date(from: DateComponents( year: year, month: month, @@ -162,16 +176,17 @@ struct SakanaUsageFetcherTests { minute: minute)) } + /// Raw server response values are UTC; browser hydration localizes them afterward. private static let billingHTML = """
Standard$20/mo
Usage limit

5-hour

-

Resets on June 23, 2026 at 10:53 PM

+

Resets on June 23, 2026 at 2:53 PM

92% used

Weekly

-

Resets on June 29, 2026 at 8:00 AM

+

Resets on June 29, 2026 at 12:00 AM

32% used

diff --git a/docs/sakana.md b/docs/sakana.md index bda143380a..2af31db60d 100644 --- a/docs/sakana.md +++ b/docs/sakana.md @@ -36,8 +36,11 @@ Alternatively, set the environment variable `SAKANA_COOKIE` to the raw cookie he - The secondary row shows the **weekly quota** as a seven-day window and uses its billing-page reset timestamp when one is present. - `usedPercent` for each window is parsed from the billing page's adjacent `% used` text. -- Reset dates are parsed from the billing page using the device's local time zone (`TimeZone.current`). - The fetcher detects `"MMMM d, yyyy 'at' h:mm a"` format strings. +- Reset dates are parsed as **UTC**, not the device's local time zone. The billing page always server-renders + "Resets on " in UTC — the browser only corrects it to the viewer's local time client-side, after JS + hydration, which this HTML-only fetcher never runs. (Parsing with `TimeZone.current` instead shifted every reset + by the device's UTC offset; see [#1826](https://github.com/steipete/CodexBar/issues/1826).) The fetcher detects + `"MMMM d, yyyy 'at' h:mm a"` format strings. - Plan name and price label (e.g. `Standard $20/mo`) are joined and surfaced as the `loginMethod` identity field for plan display in the menu. - Token cost tracking (`supportsTokenCost: false`): not supported; cost summary is unavailable.