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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 11 additions & 9 deletions Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
Expand All @@ -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(
Expand All @@ -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? {
Expand Down Expand Up @@ -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 <date>" 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)
}
Expand Down
47 changes: 31 additions & 16 deletions Tests/CodexBarTests/SakanaUsageFetcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -124,18 +123,16 @@ struct SakanaUsageFetcherTests {
@Test
func `window without reset line still maps percent`() throws {
let html = Self.billingHTML.replacing(
"<p class=\"text-muted-foreground text-xs tabular-nums\">Resets on June 23, 2026 at 10:53 PM</p>",
"<p class=\"text-muted-foreground text-xs tabular-nums\">Resets on June 23, 2026 at 2:53 PM</p>",
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
Expand All @@ -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 <date>" 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,
Expand All @@ -162,16 +176,17 @@ struct SakanaUsageFetcherTests {
minute: minute))
}

/// Raw server response values are UTC; browser hydration localizes them afterward.
private static let billingHTML = """
<main>
<div data-slot="card-title"><span>Standard</span><span>$20/mo</span></div>
<div data-slot="card-title">Usage limit</div>
<p class="font-medium text-sm">5-hour</p>
<p class="text-muted-foreground text-xs tabular-nums">Resets on June 23, 2026 at 10:53 PM</p>
<p class="text-muted-foreground text-xs tabular-nums">Resets on June 23, 2026 at 2:53 PM</p>
<button aria-label="The 5-hour window starts with your first request."></button>
<p class="text-muted-foreground text-sm">92% used</p>
<p class="font-medium text-sm">Weekly</p>
<p class="text-muted-foreground text-xs tabular-nums">Resets on June 29, 2026 at 8:00 AM</p>
<p class="text-muted-foreground text-xs tabular-nums">Resets on June 29, 2026 at 12:00 AM</p>
<button aria-label="Weekly usage resets every Monday at 00:00 UTC."></button>
<p class="text-muted-foreground text-sm">32% used</p>
</main>
Expand Down
7 changes: 5 additions & 2 deletions docs/sakana.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date>" 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.
Expand Down