diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 7b250d6a2..8460a89a3 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -766,7 +766,8 @@ enum MiniMaxUsageParser { windowMinutes: available?.windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + planPeriodEndsAt: nil) } static func parseCodingPlanRemains( @@ -1278,7 +1279,8 @@ enum MiniMaxUsageParser { usedPercent: nil, resetsAt: nil, updatedAt: now, - services: services) + services: services, + planPeriodEndsAt: nil) } private static func parseResetsAtFromTimeRange(timeRange: String, windowType: String, now: Date) -> Date? { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index f66de9d23..d2a59779d 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -11,6 +11,10 @@ public struct MiniMaxUsageSnapshot: Sendable { public let updatedAt: Date public let services: [MiniMaxServiceUsage]? public let billingSummary: MiniMaxBillingSummary? + /// The end of the current billing period for the MiniMax Token Plan subscription. + /// Derived from `MiniMaxModelRemains.endTime` — NOT the quota reset time. + /// Use this for "Plan period ends: Jun 22, 2026" display. + public let planPeriodEndsAt: Date? public var primaryService: MiniMaxServiceUsage? { // Priority: "Text Generation" > first service @@ -55,7 +59,8 @@ public struct MiniMaxUsageSnapshot: Sendable { resetsAt: Date?, updatedAt: Date, services: [MiniMaxServiceUsage]? = nil, - billingSummary: MiniMaxBillingSummary? = nil) + billingSummary: MiniMaxBillingSummary? = nil, + planPeriodEndsAt: Date? = nil) { self.planName = planName self.availablePrompts = availablePrompts @@ -67,6 +72,7 @@ public struct MiniMaxUsageSnapshot: Sendable { self.updatedAt = updatedAt self.services = services self.billingSummary = billingSummary + self.planPeriodEndsAt = planPeriodEndsAt } public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { @@ -80,7 +86,8 @@ public struct MiniMaxUsageSnapshot: Sendable { resetsAt: self.resetsAt, updatedAt: self.updatedAt, services: self.services, - billingSummary: billingSummary) + billingSummary: billingSummary, + planPeriodEndsAt: self.planPeriodEndsAt) } } diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 968962590..13d62784b 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -98,4 +98,66 @@ struct MiniMaxMenuCardModelPlanTests { #expect(model.planText == nil) } + + @Test + func `minimax menu card omits plan period ends metric when planPeriodEndsAt is nil`() throws { + // Without a reliable billing-period source, planPeriodEndsAt stays nil from parsing. + // The menu card should not render a plan-period row when planPeriodEndsAt is nil. + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 2, + limit: 10, + percent: 20, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + ], + planPeriodEndsAt: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = try #require(UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now))) + + let planPeriodMetric = model.metrics.first { $0.id == "minimax-plan-period" } + #expect(planPeriodMetric == nil) + } } diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 1313c24e9..14dba102e 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1051,3 +1051,130 @@ struct MiniMaxAPIRegionTests { #expect(origin.absoluteString == "https://api.minimaxi.com") } } + +struct MiniMaxPlanPeriodParserTests { + @Test + func `parseCodingPlanRemains does NOT derive planPeriodEndsAt from end_time`() throws { + // end_time represents the quota reset (5-hour window), NOT billing period end. + // planPeriodEndsAt should remain nil since no reliable billing-period source exists. + let json = """ + { + "base_resp": { "status_code": 0 }, + "data": { + "plan_name": "Max", + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 100, + "current_interval_usage_count": 50, + "start_time": 1779019200, + "end_time": 1782086400, + "remains_time": 86400 + } + ] + } + } + """ + + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(json.utf8), + now: now) + + // end_time is quota reset; planPeriodEndsAt must stay nil without a true billing-period field + #expect(snapshot.planPeriodEndsAt == nil) + } + + @Test + func `parseCodingPlanRemains does NOT use earliest endTime for planPeriodEndsAt`() throws { + // Even with multiple model_remains, end_time fields are quota resets. + // planPeriodEndsAt must remain nil since end_time represents quota, not billing. + let json = """ + { + "base_resp": { "status_code": 0 }, + "data": { + "plan_name": "Max", + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 100, + "current_interval_usage_count": 50, + "start_time": 1779019200, + "end_time": 1782979200, + "remains_time": 86400 + }, + { + "model_name": "MiniMax-M2", + "current_interval_total_count": 50, + "current_interval_usage_count": 10, + "start_time": 1781404800, + "end_time": 1782086400, + "remains_time": 172800 + } + ] + } + } + """ + + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(json.utf8), + now: now) + + #expect(snapshot.planPeriodEndsAt == nil) + } + + @Test + func `parseCodingPlanRemains leaves planPeriodEndsAt nil when end_time missing`() throws { + // end_time is absent from model_remains + let json = """ + { + "base_resp": { "status_code": 0 }, + "data": { + "plan_name": "Max", + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 100, + "current_interval_usage_count": 50, + "start_time": 1779019200, + "remains_time": 86400 + } + ] + } + } + """ + + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(json.utf8), + now: now) + + #expect(snapshot.planPeriodEndsAt == nil) + } + + @Test + func `withBillingSummary preserves planPeriodEndsAt`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let planDate = try #require(calendar.date(from: DateComponents(year: 2026, month: 6, day: 22))) + + let original = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 100, + currentPrompts: 50, + remainingPrompts: 50, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + planPeriodEndsAt: planDate) + + let withBilling = original.withBillingSummary(nil) + #expect(withBilling.planPeriodEndsAt == planDate) + } +}