From 9018430e901e46d54382c5717ce43cdbcf8ff21f Mon Sep 17 00:00:00 2001 From: Eohan G Date: Mon, 28 Sep 2026 00:00:49 +0800 Subject: [PATCH 1/2] feat(cost): add per-project spending details Co-authored-by: codex --- README.md | 2 +- Sources/CodingBar/RenderDebug.swift | 3 +- Sources/CodingBar/SelfTest.swift | 26 +++ Sources/CodingBar/Views/Panel/PanelTabs.swift | 186 ++++++++++++++++-- Sources/CodingBar/Views/Panel/PanelView.swift | 7 +- Sources/CodingBarCore/Aggregator.swift | 37 +++- Sources/CodingBarCore/Models.swift | 19 +- Sources/CodingBarCore/Sample.swift | 12 +- 8 files changed, 262 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index fe5cba5..bdee311 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ make package # produce dist/CodingBar.app Click the menu-bar item to open a three-tab panel: - **Overview** (总览) — outcome next to cost (git output ‖ today's spend), `$/line` & `$/commit`, the live coach (context fuel + savings tips), quota bars with burn-down forecasts, and a 7-day trend. -- **Composition** (构成) — where the money went: spend broken down by model and by project. +- **Composition** (构成) — where the money went: spend broken down by model and by project. Click a project to see its model costs and hourly or daily spend for the selected range. - **Insights** (洞察) — code output, tool-use mix, golden-hours heatmap, savings tips, and a quota-depletion forecast. > **How the numbers are computed.** *Spend* is an **estimate at standard pay-as-you-go API prices** (`Pricing.swift`), **not your subscription bill** — on a Max / ChatGPT plan, read it as "equivalent API value," not money actually charged. Models without a published rate use a visibly approximate estimate. *Code output* is **approximate git attribution**: all non-merge commits in a session's working directory within the time window — it can't tell hand-written from AI commits and excludes uncommitted work. *Codex* uses per-response token usage in recent logs; older logs use cumulative-counter deltas without charging the prior session's starting balance. diff --git a/Sources/CodingBar/RenderDebug.swift b/Sources/CodingBar/RenderDebug.swift index 9449c2e..22ed32a 100644 --- a/Sources/CodingBar/RenderDebug.swift +++ b/Sources/CodingBar/RenderDebug.swift @@ -32,7 +32,8 @@ enum RenderDebug { store.language = language store.snapshot = Self.scenarioSnapshot(scenario, language: language) store.menuMetric = metric - let view = PanelView(store: store, initialTab: tab, scrollable: false) + let view = PanelView(store: store, initialTab: tab, scrollable: false, + initialProjectPath: scenario == "project-detail" ? store.snapshot.projects.first?.path : nil) .environment(\.colorScheme, dark ? .dark : .light) write(view, to: path, scale: 2) } diff --git a/Sources/CodingBar/SelfTest.swift b/Sources/CodingBar/SelfTest.swift index 3633ac6..fb5392b 100644 --- a/Sources/CodingBar/SelfTest.swift +++ b/Sources/CodingBar/SelfTest.swift @@ -15,8 +15,23 @@ enum SelfTest { if let data = try? JSONEncoder().encode(sample), let back = try? JSONDecoder().decode(Snapshot.self, from: data) { check("sample snapshot round-trips", back.overview.spend.sessions == 7) + check("project detail round-trips", back.projects.first?.models.count == 2 + && back.projects.first?.trend.count == sample.projects.first?.trend.count) } else { check("sample snapshot round-trips", false) + check("project detail round-trips", false) + } + if let project = sample.projects.first, + let data = try? JSONEncoder().encode(project), + var legacy = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + legacy.removeValue(forKey: "models") + legacy.removeValue(forKey: "trend") + let oldData = try? JSONSerialization.data(withJSONObject: legacy) + let restored = oldData.flatMap { try? JSONDecoder().decode(ProjectStat.self, from: $0) } + check("older project JSON remains readable", restored?.models.isEmpty == true + && restored?.trend.isEmpty == true) + } else { + check("older project JSON remains readable", false) } check("humanTokens M", UsageStore.humanTokens(1_240_000).hasSuffix("M")) @@ -119,6 +134,17 @@ enum SelfTest { let monthModels = snap.overviews.first { $0.range == .month }?.models.count ?? 0 let todayModels = snap.overviews.first { $0.range == .today }?.models.count ?? 0 check("month composition ⊇ today", monthModels >= todayModels) + let projectDetailsAgree = snap.overviews.flatMap(\.projects).allSatisfy { project in + let modelCost = project.models.reduce(0) { $0 + $1.cost } + let modelTokens = project.models.reduce(0) { $0 + $1.tokens.total } + let trendCost = project.trend.reduce(0) { $0 + $1.cost } + let trendTokens = project.trend.reduce(0) { $0 + $1.tokens } + return abs(modelCost - project.cost) < 0.000_001 + && modelTokens == project.tokens.total + && abs(trendCost - project.cost) < 0.000_001 + && trendTokens == project.tokens.total + } + check("project detail matches range totals", projectDetailsAgree) // ── Refresh pass reuses work ──────────────────────────────────────────── // Both assertions guard a silent regression: the numbers stay correct either diff --git a/Sources/CodingBar/Views/Panel/PanelTabs.swift b/Sources/CodingBar/Views/Panel/PanelTabs.swift index 763058d..14439bd 100644 --- a/Sources/CodingBar/Views/Panel/PanelTabs.swift +++ b/Sources/CodingBar/Views/Panel/PanelTabs.swift @@ -425,9 +425,18 @@ struct CostTab: View { @ObservedObject var store: UsageStore @State private var modelsExpanded = false @State private var projectsExpanded = false + @State private var selectedProject: ProjectStat? + @State private var selectedProjectRange: Range = .today + @State private var timelineExpanded = false private let modelCap = 4 private let projectCap = 5 + init(store: UsageStore, initialProject: ProjectStat? = nil) { + self.store = store + self._selectedProject = State(initialValue: initialProject) + self._selectedProjectRange = State(initialValue: store.selectedRange) + } + private var snap: Snapshot { store.snapshot } private var metric: MenuMetric { store.menuMetric } private var range: Range { store.selectedRange } @@ -458,6 +467,16 @@ struct CostTab: View { private var shownProjects: [ProjectStat] { projectsExpanded ? sortedProjects : Array(sortedProjects.prefix(projectCap)) } var body: some View { + Group { + if let selectedProject { + projectDetail(selectedProject) + } else { + costOverview + } + } + } + + private var costOverview: some View { VStack(spacing: 0) { DCSection { VStack(alignment: .leading, spacing: 0) { @@ -499,6 +518,130 @@ struct CostTab: View { } } + private func projectDetail(_ selected: ProjectStat) -> some View { + let project = (snap.overviews.first { $0.range == selectedProjectRange }?.projects + ?? snap.projects).first { $0.path == selected.path } ?? selected + let models = project.models + let maxCost = max(models.map(\.cost).max() ?? 0, 1e-9) + let maxTokens = max(Double(models.map { $0.tokens.total }.max() ?? 0), 1) + return VStack(spacing: 0) { + DCSection { + VStack(alignment: .leading, spacing: 0) { + HStack { + Button { selectedProject = nil } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left").font(.system(size: 9, weight: .semibold)) + Text(lang.t("By project", "按项目")).font(.system(size: 10.5, weight: .medium)) + } + .foregroundStyle(dc.accent) + .contentShape(Rectangle()) + } + .buttonStyle(.plain).focusEffectDisabled() + Spacer() + Text(projectRangeLabel).font(.system(size: 10)).foregroundStyle(dc.fg3) + } + .padding(.bottom, 15) + + Text(project.name).font(.system(size: 17, weight: .semibold)).foregroundStyle(dc.fg) + .lineLimit(2) + Text(project.path).font(.system(size: 9.5)).foregroundStyle(dc.fg3) + .lineLimit(2).textSelection(.enabled).padding(.top, 3) + + Text(metric == .cost ? Panel.usd(project.cost) : Panel.tok(project.tokens.total)) + .font(.system(size: 32, weight: .bold)).monospacedDigit().tracking(-0.7) + .foregroundStyle(dc.fg).padding(.top, 14) + Text(metric == .cost + ? lang.t("\(Panel.tok(project.tokens.total)) tokens · standard API estimate", + "\(Panel.tok(project.tokens.total)) Token · 标准 API 价格估算") + : lang.t("\(Panel.usd(project.cost)) · standard API estimate", + "\(Panel.usd(project.cost)) · 标准 API 价格估算")) + .font(.system(size: 10)).foregroundStyle(dc.fg3).padding(.top, 2) + + if project.trend.count >= 3 { + DCSparkline(values: metric == .cost + ? project.trend.map(\.cost) + : project.trend.map { Double($0.tokens) }) + .padding(.top, 15) + HStack { + Text(projectTrendStart(project.trend[0].date)) + Spacer() + Text(lang.t("now", "现在")) + } + .font(.system(size: 9)).foregroundStyle(dc.fg3) + .padding(.horizontal, 3).padding(.top, 6) + } + } + } + if !models.isEmpty { + DCSection { + VStack(alignment: .leading, spacing: 11) { + DCLabel(metric == .cost ? lang.t("By model · cost", "按模型 · 花费") + : lang.t("By model · tokens", "按模型 · Token")) + ForEach(metric == .cost ? models : models.sorted { $0.tokens.total > $1.tokens.total }) { + modelRow($0, maxCost: maxCost, maxTokens: maxTokens) + } + } + } + } + if project.trend.contains(where: { $0.tokens > 0 }) { + projectTimeline(project.trend) + } + } + } + + private func projectTimeline(_ trend: [DayPoint]) -> some View { + let points = trend.filter { $0.tokens > 0 }.reversed() + let maxValue = max(points.map { metric == .cost ? $0.cost : Double($0.tokens) }.max() ?? 0, 1e-9) + let shown = timelineExpanded ? Array(points) : Array(points.prefix(7)) + return DCSection { + VStack(alignment: .leading, spacing: 10) { + DCLabel(selectedProjectRange == .today + ? lang.t("By hour", "按小时") : lang.t("By day", "按天")) + ForEach(shown) { point in + HStack(spacing: 9) { + Text(projectTimeLabel(point.date)) + .font(.system(size: 10.5)).foregroundStyle(dc.fg2) + .frame(width: 42, alignment: .leading) + GeometryReader { g in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2).fill(dc.track) + RoundedRectangle(cornerRadius: 2).fill(dc.accent.opacity(0.65)) + .frame(width: g.size.width * max(0.03, (metric == .cost ? point.cost : Double(point.tokens)) / maxValue)) + } + } + .frame(height: 4) + Text(metric == .cost ? Panel.usd(point.cost) : Panel.tok(point.tokens)) + .font(.system(size: 10.5, weight: .semibold)).monospacedDigit() + .foregroundStyle(dc.fg).frame(width: 69, alignment: .trailing) + } + } + if points.count > 7 { + expandToggle(timelineExpanded, points.count - 7) { timelineExpanded.toggle() } + } + } + } + } + + private var projectRangeLabel: String { + switch selectedProjectRange { + case .today: return lang.t("Today", "今日") + case .week: return lang.t("Last 7d", "近 7 天") + case .month: return lang.t("Last 30d", "近 30 天") + } + } + + private func projectTrendStart(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = selectedProjectRange == .today ? "H:mm" : "M/d" + return formatter.string(from: date) + } + + private func projectTimeLabel(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = selectedProjectRange == .today ? "H:00" : "M/d" + return formatter.string(from: date) + } + // MARK: 用量归因 — Skills / Subagents / Plugins / MCP servers (`/usage` "% of usage"). // Claude-only (Codex carries no attribution tags); follows the range pill + metric. @@ -654,10 +797,10 @@ struct CostTab: View { .buttonStyle(.plain).focusEffectDisabled() } - private func modelRow(_ m: ModelStat) -> some View { + private func modelRow(_ m: ModelStat, maxCost: Double? = nil, maxTokens: Double? = nil) -> some View { let name = (m.provider == .claude ? "Claude " : "Codex ") + Pricing.displayName(forCanonicalKey: m.model) let metricVal = metric == .cost ? m.cost : Double(m.tokens.total) - let maxVal = metric == .cost ? maxModelCost : maxModelTokens + let maxVal = metric == .cost ? (maxCost ?? maxModelCost) : (maxTokens ?? maxModelTokens) let share = max(3.0, metricVal / maxVal * 100) return VStack(spacing: 4) { HStack(spacing: 7) { @@ -694,24 +837,35 @@ struct CostTab: View { let metricVal = metric == .cost ? p.cost : Double(p.tokens.total) let maxVal = metric == .cost ? maxProjCost : maxProjTokens let share = max(3.0, metricVal / maxVal * 100) - return HStack(spacing: 9) { - VStack(alignment: .leading, spacing: 4) { - Text(p.name).font(.system(size: 11, weight: .medium)).foregroundStyle(dc.fg).lineLimit(1) - GeometryReader { g in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 2).fill(dc.track) - RoundedRectangle(cornerRadius: 2).fill(dc.accent.opacity(0.55)) - .frame(width: g.size.width * share / 100) + return Button { + selectedProject = p + selectedProjectRange = range + timelineExpanded = false + } label: { + HStack(spacing: 9) { + VStack(alignment: .leading, spacing: 4) { + Text(p.name).font(.system(size: 11, weight: .medium)).foregroundStyle(dc.fg).lineLimit(1) + GeometryReader { g in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2).fill(dc.track) + RoundedRectangle(cornerRadius: 2).fill(dc.accent.opacity(0.55)) + .frame(width: g.size.width * share / 100) + } } + .frame(height: 3) } - .frame(height: 3) - } - VStack(alignment: .trailing, spacing: 1) { - Text(metric == .cost ? Panel.usd(p.cost) : Panel.tok(p.tokens.total)) - .font(.system(size: 11, weight: .semibold)).monospacedDigit().foregroundStyle(dc.fg) - Text(lastLabel(p.lastActive)).font(.system(size: 9)).foregroundStyle(dc.fg3) + VStack(alignment: .trailing, spacing: 1) { + Text(metric == .cost ? Panel.usd(p.cost) : Panel.tok(p.tokens.total)) + .font(.system(size: 11, weight: .semibold)).monospacedDigit().foregroundStyle(dc.fg) + Text(lastLabel(p.lastActive)).font(.system(size: 9)).foregroundStyle(dc.fg3) + } + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .semibold)).foregroundStyle(dc.fg3) } + .contentShape(Rectangle()) } + .buttonStyle(.plain).focusEffectDisabled() + .help(p.path) } private func lastLabel(_ d: Date) -> String { diff --git a/Sources/CodingBar/Views/Panel/PanelView.swift b/Sources/CodingBar/Views/Panel/PanelView.swift index 3fe9ab7..469998c 100644 --- a/Sources/CodingBar/Views/Panel/PanelView.swift +++ b/Sources/CodingBar/Views/Panel/PanelView.swift @@ -17,11 +17,14 @@ struct PanelView: View { /// Live popover scrolls within a screen-bounded height; offscreen rendering /// (ImageRenderer has no update pass) uses natural height instead. let scrollable: Bool + let initialProjectPath: String? - init(store: UsageStore, initialTab: Int = 0, scrollable: Bool = true, initialSettings: Bool = false) { + init(store: UsageStore, initialTab: Int = 0, scrollable: Bool = true, + initialSettings: Bool = false, initialProjectPath: String? = nil) { self.store = store self._tab = State(initialValue: initialTab) self.scrollable = scrollable + self.initialProjectPath = initialProjectPath self._showSettings = State(initialValue: initialSettings) } @@ -78,7 +81,7 @@ struct PanelView: View { @ViewBuilder private var tabContent: some View { switch tab { - case 1: CostTab(store: store) + case 1: CostTab(store: store, initialProject: snap.projects.first { $0.path == initialProjectPath }) case 2: InsightsTab(store: store) default: OverviewTab(store: store, onShowInsights: { tab = 2 }) } diff --git a/Sources/CodingBarCore/Aggregator.swift b/Sources/CodingBarCore/Aggregator.swift index 794aab1..e2d7a55 100644 --- a/Sources/CodingBarCore/Aggregator.swift +++ b/Sources/CodingBarCore/Aggregator.swift @@ -143,7 +143,8 @@ public enum Aggregator { // (the 构成 tab now follows the range selector). `pricedExact` is false when any // contributing record priced via a family guess / fallback rate. let home = FileManager.default.homeDirectoryForCurrentUser.path - func breakdown(from records: [RawRecord]) -> (models: [ModelStat], projects: [ProjectStat]) { + func breakdown(from records: [RawRecord], trendDates: [Date] = [], + trendUnit: Calendar.Component? = nil) -> (models: [ModelStat], projects: [ProjectStat]) { var modelMap: [String: (model: String, provider: Provider, tokens: TokenBreakdown, cost: Double, exact: Bool)] = [:] for r in records { let key = Pricing.normalize(model: r.model) @@ -162,20 +163,39 @@ public enum Aggregator { } .sorted { $0.cost > $1.cost } - var projectMap: [String: (tokens: TokenBreakdown, cost: Double, lastActive: Date)] = [:] + var projectMap: [String: (tokens: TokenBreakdown, cost: Double, lastActive: Date, + models: [String: ModelStat], buckets: [Date: DayPoint])] = [:] for r in records { guard !r.cwd.isEmpty else { continue } - var entry = projectMap[r.cwd] ?? (tokens: TokenBreakdown(), cost: 0, lastActive: Date.distantPast) + var entry = projectMap[r.cwd] ?? (tokens: TokenBreakdown(), cost: 0, + lastActive: Date.distantPast, models: [:], buckets: [:]) + let cost = recordCost(r) entry.tokens += r.tokens - entry.cost += recordCost(r) + entry.cost += cost if r.timestamp > entry.lastActive { entry.lastActive = r.timestamp } + let key = Pricing.normalize(model: r.model) + let modelKey = r.provider.rawValue + "·" + key + var model = entry.models[modelKey] ?? ModelStat(model: key, provider: r.provider, + tokens: .init(), cost: 0) + model.tokens += r.tokens + model.cost += cost + model.pricedExact = model.pricedExact && Pricing.priceIsExact(model: r.model) + entry.models[modelKey] = model + if let trendUnit, let bucketDate = cal.dateInterval(of: trendUnit, for: r.timestamp)?.start { + var point = entry.buckets[bucketDate] ?? DayPoint(date: bucketDate, cost: 0, tokens: 0) + point.cost += cost + point.tokens += r.tokens.total + entry.buckets[bucketDate] = point + } projectMap[r.cwd] = entry } let projects: [ProjectStat] = projectMap .map { cwd, entry -> ProjectStat in let displayPath = cwd.hasPrefix(home) ? "~" + cwd.dropFirst(home.count) : cwd return ProjectStat(name: URL(fileURLWithPath: cwd).lastPathComponent, path: displayPath, - tokens: entry.tokens, cost: entry.cost, lastActive: entry.lastActive) + tokens: entry.tokens, cost: entry.cost, lastActive: entry.lastActive, + models: entry.models.values.sorted { $0.cost > $1.cost }, + trend: trendDates.map { entry.buckets[$0] ?? DayPoint(date: $0, cost: 0, tokens: 0) }) } .sorted { $0.cost > $1.cost } .prefix(8) @@ -286,15 +306,16 @@ public enum Aggregator { let prevTok = tokensTotal(from: prevStart, to: start) let deltaTok: Double? = prevTok > 0 ? Double(s.tokens.total - prevTok) / Double(prevTok) * 100 : nil let rangeRecords = allRecords.filter { $0.timestamp >= start && $0.timestamp <= now } - let bd = breakdown(from: rangeRecords) - let ctxAttr = contextAttribution(from: rangeRecords) - let usageAttr = usageAttribution(from: rangeRecords) let trend: [DayPoint] switch range { case .today: trend = trendSeries(hourBucketsToday()) case .week: trend = trendSeries(dayBuckets(7)) case .month: trend = trendSeries(dayBuckets(30)) } + let bd = breakdown(from: rangeRecords, trendDates: trend.map(\.date), + trendUnit: range == .today ? .hour : .day) + let ctxAttr = contextAttribution(from: rangeRecords) + let usageAttr = usageAttribution(from: rangeRecords) return Overview( range: range, spend: PeriodTotals(cost: s.cost, tokens: s.tokens, sessions: s.cwds.count), diff --git a/Sources/CodingBarCore/Models.swift b/Sources/CodingBarCore/Models.swift index e379570..9f4ba19 100644 --- a/Sources/CodingBarCore/Models.swift +++ b/Sources/CodingBarCore/Models.swift @@ -53,8 +53,25 @@ public struct ProjectStat: Codable, Sendable, Identifiable { public var tokens: TokenBreakdown public var cost: Double public var lastActive: Date - public init(name: String, path: String, tokens: TokenBreakdown, cost: Double, lastActive: Date) { + /// Breakdown for this project's selected range. Empty for older snapshots. + public var models: [ModelStat] + public var trend: [DayPoint] + public init(name: String, path: String, tokens: TokenBreakdown, cost: Double, lastActive: Date, + models: [ModelStat] = [], trend: [DayPoint] = []) { self.name = name; self.path = path; self.tokens = tokens; self.cost = cost; self.lastActive = lastActive + self.models = models; self.trend = trend + } + + private enum CodingKeys: String, CodingKey { case name, path, tokens, cost, lastActive, models, trend } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + name = try values.decode(String.self, forKey: .name) + path = try values.decode(String.self, forKey: .path) + tokens = try values.decode(TokenBreakdown.self, forKey: .tokens) + cost = try values.decode(Double.self, forKey: .cost) + lastActive = try values.decode(Date.self, forKey: .lastActive) + models = try values.decodeIfPresent([ModelStat].self, forKey: .models) ?? [] + trend = try values.decodeIfPresent([DayPoint].self, forKey: .trend) ?? [] } } diff --git a/Sources/CodingBarCore/Sample.swift b/Sources/CodingBarCore/Sample.swift index 5b5b455..426a973 100644 --- a/Sources/CodingBarCore/Sample.swift +++ b/Sources/CodingBarCore/Sample.swift @@ -11,6 +11,12 @@ public extension Snapshot { let d = cal.date(byAdding: .hour, value: -(hourly.count - 1 - i), to: now) ?? now return DayPoint(date: d, cost: c, tokens: Int(c * 290_000)) } + let projectTrendTotal = hourly.reduce(0, +) + var projectTrend = trend.map { point in + DayPoint(date: point.date, cost: point.cost * 3.10 / projectTrendTotal, + tokens: Int(point.cost * 890_000 / projectTrendTotal)) + } + projectTrend[projectTrend.count - 1].tokens += 890_000 - projectTrend.reduce(0) { $0 + $1.tokens } let heat: [[Double]] = (0..<7).map { d in (0..<12).map { h in var base = 0.22 @@ -61,7 +67,11 @@ public extension Snapshot { rhythm: Rhythm(turnsPerSession: 11, avgMinutes: 22, interruptRate: 0.18), heatmap: Heatmap(cells: heat, peakLabel: "22:00–24:00")), projects: [ - ProjectStat(name: "coding-bar", path: "~/dev/coding-bar", tokens: TokenBreakdown(input: 220_000, output: 70_000, cacheRead: 600_000), cost: 3.10, lastActive: now.addingTimeInterval(-720)), + ProjectStat(name: "coding-bar", path: "~/dev/coding-bar", tokens: TokenBreakdown(input: 220_000, output: 70_000, cacheRead: 600_000), cost: 3.10, lastActive: now.addingTimeInterval(-720), + models: [ + ModelStat(model: "Opus 4.8", provider: .claude, tokens: TokenBreakdown(input: 160_000, output: 50_000, cacheRead: 490_000), cost: 2.40), + ModelStat(model: "Sonnet 4.6", provider: .claude, tokens: TokenBreakdown(input: 60_000, output: 20_000, cacheRead: 110_000), cost: 0.70), + ], trend: projectTrend), ProjectStat(name: "api-svc", path: "~/work/api-svc", tokens: TokenBreakdown(input: 60_000, output: 20_000, cacheRead: 130_000), cost: 0.80, lastActive: now.addingTimeInterval(-7200)), ProjectStat(name: "dotfiles", path: "~/.dotfiles", tokens: TokenBreakdown(input: 18_000, output: 6_000, cacheRead: 36_000), cost: 0.20, lastActive: now.addingTimeInterval(-86_400)), ], From f893d642059ce33eaf720753976112496e07e089 Mon Sep 17 00:00:00 2001 From: Eohan G Date: Mon, 28 Sep 2026 00:05:24 +0800 Subject: [PATCH 2/2] docs: add v1.2.0 release notes Co-authored-by: codex --- release-notes/v1.2.0.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 release-notes/v1.2.0.md diff --git a/release-notes/v1.2.0.md b/release-notes/v1.2.0.md new file mode 100644 index 0000000..d13bb05 --- /dev/null +++ b/release-notes/v1.2.0.md @@ -0,0 +1,3 @@ +- Click a project in the Cost tab to see its total, model costs, and hourly or daily spend for the selected Today, 7d, or 30d range. + +**Full Changelog**: https://github.com/Gnonymous/CodingBar/compare/v1.1.10...v1.2.0