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
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,35 @@

## [Не выпущено]

### Исправлено

- **Ряд недели больше не приходит к 100 % дважды.** При сбросе среди ночи
последние сутки окна — огрызок в пару часов, целиком лежащий вне рабочих
часов: плана он не набирает нисколько и приходил к той же сотне, что и
предыдущие сутки. Две нижние строки показывали одно и то же. Теперь такой
огрызок своей строки не получает — день сброса остаётся в ряду первой
половиной, — а расход, случившийся в нём, доливается в последнюю строку:
показать его больше негде, а расходиться с итогом недели в футере ряд не
должен. Там, где у последних суток рабочие часы есть, всё как было.
- **Сброс счёта больше не стирает суточные полосы.** Отсечка убирает прежний
расход из локального счёта — но не из недельного итога: его считает сервер,
который об отсечке не знает. Форма недели строилась по одним лишь записям
после сброса, и пока их не набиралось, панель показывала `— / 43 %` во всех
сутках при непустой неделе: сверху 15 %, а по дням пусто. Теперь до-отсечный
расход помнится отдельно и раскладывается по дням тем же ровным темпом,
которым считается план, — от начала недели до момента сброса. Ряд идёт вдоль
плановой линии и приходит к общему проценту в сутках сброса; горкой в один
день этот расход не сваливается, иначе в тех сутках загорался бы перерасход,
которого не было. На локальную оценку это не влияет — там отсечка работает
по-прежнему, иначе чужой расход снова считался бы своим.
- **Переключение аккаунта не проигрывается анимацией.** Полосы уезжали в ноль
и приползали на новые места, будто это те же цифры изменились, — хотя после
переключения панель показывает уже другую неделю. Строки привязаны к
аккаунту и при смене рисуются сразу на своих местах.

## [0.2.2] — 2026-08-31

### Исправлено
### Исправлено

- **Обновление больше не оставляет приложение мёртвым.** 0.2.1 из релиза
падала при первой же отрисовке панели: знак Claude в переключателе аккаунтов
Expand Down
38 changes: 23 additions & 15 deletions Sources/ClaudeWeekApp/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,28 +49,36 @@ struct PopoverView: View {
calendar: model.config.calendar,
animated: !reduceMotion
)
.id(model.config.activeAccount)
}

Divider().overlay(palette.separator.color)

if let snapshot = model.snapshot {
// Разбивка встаёт ровно на место дней, в ту же сетку: щелчок
// по цифрам меняет содержание строк, а не открывает второе
// место, где те же проценты сказаны по-своему. Дневной план
// выключен в настройках — дней вовсе нет, и на их месте одна
// полоса на всю неделю, тем же видом, что у сессии.
if model.showsModels {
models(snapshot)
} else if appearance.showsPlan {
days(snapshot)
// Полосы привязаны к аккаунту: у другого аккаунта другие цифры,
// а не изменившиеся свои. Без этого переключение проигрывалось
// анимацией — полосы уезжали в ноль и приползали на новое место,
// хотя показывают уже совсем другую неделю.
Group {
if let snapshot = model.snapshot {
// Разбивка встаёт ровно на место дней, в ту же сетку: щелчок
// по цифрам меняет содержание строк, а не открывает второе
// место, где те же проценты сказаны по-своему. Дневной план
// выключен в настройках — дней вовсе нет, и на их месте одна
// полоса на всю неделю, тем же видом, что у сессии.
if model.showsModels {
models(snapshot)
} else if appearance.showsPlan {
days(snapshot)
} else {
weekRow(snapshot)
}
} else if let notice = model.notice {
noticeRow(notice)
} else {
weekRow(snapshot)
placeholders
}
} else if let notice = model.notice {
noticeRow(notice)
} else {
placeholders
}
.id(model.config.activeAccount)

Divider().overlay(palette.separator.color)
footer
Expand Down
34 changes: 30 additions & 4 deletions Sources/ClaudeWeekCore/LocalProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ struct MessageUsage {
public struct LocalUsage: Sendable {
/// Условная стоимость недели в долларах.
public let totalCost: Double
/// Стоимость окна до отсечки счёта — то, что в счёт нынешнего аккаунта
/// не идёт. В `totalCost` и `costByDay` её нет намеренно: локальная
/// оценка итога считает только свой расход. Нужна тому, у кого итог уже
/// есть от сервера: он-то знает про всю неделю, и форму надо разложить
/// по всему окну, а не по огрызку после отсечки.
public let costBeforeCount: Double
/// Момент отсечки, если она стоит внутри окна. Нужен там же, где и
/// `costBeforeCount`: разложить досчётный расход можно только зная, по
/// какому отрезку недели он размазан.
public let countFrom: Date?
/// Стоимость по суткам окна; nil — сутки ещё не наступили.
public let costByDay: [Double?]
/// Чем именно потрачено — от дорогого к дешёвому.
Expand All @@ -213,8 +223,14 @@ public struct LocalUsage: Sendable {
public let filesRead: Int
public let window: WeekWindow

/// Вся стоимость окна, вместе с досчётной. Ноль — форму строить не из
/// чего, ни своей, ни чужой.
public var grossCost: Double { totalCost + costBeforeCount }

public init(
totalCost: Double,
costBeforeCount: Double = 0,
countFrom: Date? = nil,
costByDay: [Double?],
byModel: [ModelUsage] = [],
recordCount: Int,
Expand All @@ -223,6 +239,8 @@ public struct LocalUsage: Sendable {
window: WeekWindow
) {
self.totalCost = totalCost
self.costBeforeCount = costBeforeCount
self.countFrom = countFrom
self.costByDay = costByDay
self.byModel = byModel
self.recordCount = recordCount
Expand Down Expand Up @@ -402,6 +420,7 @@ public actor LocalProvider: UsageProvider {
var perDay = [Double](repeating: 0, count: window.slotCount)
var perModel: [String: (cost: Double, tokens: TokenCounts, messages: Int)] = [:]
var total = 0.0
var beforeCount = 0.0
var counted = 0

for entry in index.files.values {
Expand All @@ -413,12 +432,17 @@ public actor LocalProvider: UsageProvider {
// Записи позже `now` бывают при калибровке: она смотрит
// на прошлый момент, а индекс уже знает про более свежие.
guard record.timestamp <= now,
// Всё, что раньше отсечки, накоплено прежним аккаунтом:
// в лимит нынешнего оно не идёт, хотя лежит в тех же
// файлах транскриптов.
countFrom.map({ record.timestamp >= $0 }) ?? true,
let day = window.dayIndex(for: record.timestamp)
else { continue }
// Всё, что раньше отсечки, накоплено прежним аккаунтом: в
// лимит нынешнего оно не идёт, хотя лежит в тех же файлах
// транскриптов. Но и выбрасывать его насовсем нельзя —
// из него собирается форма недели, когда итог приходит от
// сервера: сервер-то считает всю неделю целиком.
guard countFrom.map({ record.timestamp >= $0 }) ?? true else {
beforeCount += record.cost
continue
}
perDay[day] += record.cost
total += record.cost
counted += 1
Expand All @@ -438,6 +462,8 @@ public actor LocalProvider: UsageProvider {

return LocalUsage(
totalCost: total,
costBeforeCount: beforeCount,
countFrom: countFrom,
costByDay: costByDay,
byModel: LocalProvider.breakdown(perModel, total: total),
recordCount: counted,
Expand Down
44 changes: 40 additions & 4 deletions Sources/ClaudeWeekCore/OfficialProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,9 @@ public actor OfficialProvider: UsageProvider {
/// Локальный расчёт за то же окно; nil — транскриптов нет или они пусты.
private func localUsage(window: WeekWindow, now: Date) async -> LocalUsage? {
guard let shape else { return nil }
guard let local = try? await shape.scan(window: window, now: now), local.totalCost > 0 else {
// Годится и расход, целиком отсечённый сбросом счёта: итог недели
// здесь свой, от сервера, и форму надо разложить по всему окну.
guard let local = try? await shape.scan(window: window, now: now), local.grossCost > 0 else {
Log.debug("локальной формы нет — панель покажет только итог")
return nil
}
Expand All @@ -319,14 +321,48 @@ public actor OfficialProvider: UsageProvider {

/// Форма недели: доля каждых суток в локальной стоимости, умноженная на
/// официальный процент. Итог точный, форма правдоподобная.
///
/// Расход до отсечки счёта раскладывается по плану отрезка «начало окна →
/// отсечка». Подённой правды о нём нет — отсечка на то и стоит, чтобы
/// прежний счёт не смешивался с нынешним, — но и потерять его нельзя:
/// сервер эти проценты уже насчитал, и без них ряд стоял бы пустым при
/// непустой неделе. Ровный темп в рабочие часы — та же догадка, по
/// которой считается план, и единственная, которая не выдумывает ни
/// всплеска, ни простоя: сложенный горкой в одни сутки, этот расход
/// зажигал в них перерасход, которого не было.
private func shapeByDay(_ local: LocalUsage?, total: Double, window: WeekWindow) -> [Double?] {
guard let local else { return Array(repeating: nil, count: window.slotCount) }
guard let local, local.grossCost > 0 else {
return Array(repeating: nil, count: window.slotCount)
}

let carried = local.costBeforeCount / local.grossCost * total
let spread = carriedShares(local.countFrom, window: window)
var running = 0.0
return local.costByDay.map { cost in

return local.costByDay.enumerated().map { index, cost in
guard let cost else { return nil }
running += cost
return running / local.totalCost * total
let share = index < spread.count ? spread[index] : 1
return carried * share + running / local.grossCost * total
}
}

/// Какая доля досчётного расхода приходится на конец каждых суток окна.
///
/// Считается планом: доля рабочего времени, прошедшего к концу этих суток,
/// от всего рабочего времени до отсечки. Ряд от этого идёт вдоль плановой
/// линии и упирается в общий досчётный процент ровно в сутках отсечки.
/// Рабочего времени до неё нет вовсе (сброс счёта в первую же ночь окна) —
/// весь кусок отдаём первым суткам: делить нечего, а терять нельзя.
private func carriedShares(_ countFrom: Date?, window: WeekWindow) -> [Double] {
guard let cut = countFrom, cut > window.start else {
return Array(repeating: 1, count: window.slotCount)
}
let whole = window.progress(at: cut)
guard whole > 0 else { return Array(repeating: 1, count: window.slotCount) }

return (0..<window.slotCount).map { index in
min(window.progress(at: window.dayStart(index + 1)) / whole, 1)
}
}
}
35 changes: 34 additions & 1 deletion Sources/ClaudeWeekCore/Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ public struct UsageSnapshot: Sendable {
/// пятница приходит к 100 %. Сутки за скобкой остаются в недельной.
public func rows(at now: Date) -> [DayUsage] {
let base = window.scaleBase(at: now)
return window.rowOrder(at: now).compactMap { index in
let order = window.rowOrder(at: now)
let rows = order.compactMap { index -> DayUsage? in
guard byDay.indices.contains(index) else { return nil }
let day = byDay[index]
guard let base, index >= base else { return day }
Expand All @@ -255,6 +256,38 @@ public struct UsageSnapshot: Sendable {
isPartial: day.isPartial
)
}
return fillingTail(rows, at: now, order: order, base: base)
}

/// Доливает в последнюю строку ряда расход тех последних суток окна,
/// которым строки не досталось.
///
/// Огрызок перед сбросом среди ночи плана не набирает и своей строки не
/// получает (`WeekWindow.rowSlots`), но потрачено в нём по-настоящему.
/// Показать этот расход больше негде, а без него ряд заканчивался бы
/// числом меньше недельного итога — и разница молча расходилась бы с
/// футером, где итог свой.
private func fillingTail(_ rows: [DayUsage], at now: Date, order: [Int], base: Int?) -> [DayUsage] {
let tail = window.slotCount - 1
guard tail >= 0, byDay.indices.contains(tail), !order.contains(tail),
now >= window.dayStart(tail), let last = rows.last
else { return rows }

let used = base.map { rebased(byDay[tail].usedPercent, base: $0) } ?? byDay[tail].usedPercent
// Значения накопительные: меньше того, что уже в строке, хвост дать
// не может, а равное доливать незачем.
guard let used, used > (last.usedPercent ?? 0) else { return rows }

var result = rows
result[result.count - 1] = DayUsage(
index: last.index,
start: last.start,
end: last.end,
planPercent: last.planPercent,
usedPercent: used,
isPartial: last.isPartial
)
return result
}

/// Факт в шкале ряда: какая доля остатка, что был к началу недели, съедена
Expand Down
12 changes: 11 additions & 1 deletion Sources/ClaudeWeekCore/WeekWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,20 @@ public struct WeekWindow: Sendable, Equatable {
public func rowSlots(at date: Date) -> Range<Int> {
guard splitsResetDay else { return 0..<slotCount }
let lastDayStart = dayBounds[slotCount - 1]
let offset = date >= lastDayStart ? 1 : 0
let offset = date >= lastDayStart && tailEarnsPlan ? 1 : 0
return offset..<(offset + rowCount)
}

/// Растёт ли план в последних сутках окна. При сбросе среди ночи их
/// огрызок целиком лежит вне рабочих часов, плана не набирает нисколько
/// и приходит к тем же 100 %, что и предыдущие сутки, — две одинаковые
/// сотни подряд в ряду. Тогда день сброса остаётся в нём своей первой
/// половиной: у неё хотя бы есть собственный вес.
private var tailEarnsPlan: Bool {
guard splitsResetDay else { return true }
return workHours.seconds(from: dayBounds[slotCount - 1], to: end, calendar: calendar) > 0
}

/// Номера суток окна в том порядке, в каком они лягут строками панели.
///
/// При `weekStart == .reset` это подряд идущие сутки от сброса — как
Expand Down
Loading