From 4e441c0c9fff1e3341e0bee1111cdc355ccf28a6 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Tue, 1 Sep 2026 09:11:23 +0300 Subject: [PATCH] =?UTF-8?q?fix(week):=20=D0=BE=D0=B4=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D1=82=D0=BD=D1=8F=20=D0=B2=20=D1=80=D1=8F=D0=B4=D1=83=20?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D1=81=D1=85=D0=BE=D0=B4=20=D0=B4=D0=BE=20?= =?UTF-8?q?=D1=81=D0=B1=D1=80=D0=BE=D1=81=D0=B0=20=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D0=BC=D0=B5=D1=81=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сброс среди ночи оставлял последним суткам окна огрызок в пару часов целиком вне рабочих часов: плана он не набирал нисколько и повторял сотню предыдущих суток — две одинаковые строки подряд. Своей строки такой огрызок больше не получает, а расход, случившийся в нём, доливается в последнюю строку ряда: показать его больше негде, а расходиться с итогом в футере ряд не должен. Сброс счёта отсекает прежний расход от локальной оценки, но не от недельного итога — его считает сервер, который об отсечке не знает. Форма строилась по одним записям после сброса, и панель показывала прочерки во всех сутках при непустой неделе. Теперь до-отсечный расход помнится отдельно и раскладывается по дням тем же ровным темпом, каким считается план: ряд идёт вдоль плановой линии и приходит к общему проценту в сутках сброса. Горкой в один день его не сваливаем — там загорался бы перерасход, которого не было. Переключение аккаунта больше не проигрывается анимацией: строки привязаны к аккаунту и при смене рисуются сразу на своих местах. --- CHANGELOG.md | 28 ++++++- Sources/ClaudeWeekApp/PopoverView.swift | 38 ++++++---- Sources/ClaudeWeekCore/LocalProvider.swift | 34 ++++++++- Sources/ClaudeWeekCore/OfficialProvider.swift | 44 ++++++++++- Sources/ClaudeWeekCore/Snapshot.swift | 35 ++++++++- Sources/ClaudeWeekCore/WeekWindow.swift | 12 ++- .../ClaudeWeekTests/LocalProviderTests.swift | 2 + .../OfficialProviderTests.swift | 74 +++++++++++++++++-- Sources/ClaudeWeekTests/PlanTests.swift | 24 ++++++ Sources/ClaudeWeekTests/WeekWindowTests.swift | 31 ++++++++ docs/ARCHITECTURE.md | 18 ++++- docs/ROADMAP.md | 10 ++- docs/USAGE.md | 9 +++ 13 files changed, 324 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 977c066..b9afa46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,35 @@ ## [Не выпущено] +### Исправлено + +- **Ряд недели больше не приходит к 100 % дважды.** При сбросе среди ночи + последние сутки окна — огрызок в пару часов, целиком лежащий вне рабочих + часов: плана он не набирает нисколько и приходил к той же сотне, что и + предыдущие сутки. Две нижние строки показывали одно и то же. Теперь такой + огрызок своей строки не получает — день сброса остаётся в ряду первой + половиной, — а расход, случившийся в нём, доливается в последнюю строку: + показать его больше негде, а расходиться с итогом недели в футере ряд не + должен. Там, где у последних суток рабочие часы есть, всё как было. +- **Сброс счёта больше не стирает суточные полосы.** Отсечка убирает прежний + расход из локального счёта — но не из недельного итога: его считает сервер, + который об отсечке не знает. Форма недели строилась по одним лишь записям + после сброса, и пока их не набиралось, панель показывала `— / 43 %` во всех + сутках при непустой неделе: сверху 15 %, а по дням пусто. Теперь до-отсечный + расход помнится отдельно и раскладывается по дням тем же ровным темпом, + которым считается план, — от начала недели до момента сброса. Ряд идёт вдоль + плановой линии и приходит к общему проценту в сутках сброса; горкой в один + день этот расход не сваливается, иначе в тех сутках загорался бы перерасход, + которого не было. На локальную оценку это не влияет — там отсечка работает + по-прежнему, иначе чужой расход снова считался бы своим. +- **Переключение аккаунта не проигрывается анимацией.** Полосы уезжали в ноль + и приползали на новые места, будто это те же цифры изменились, — хотя после + переключения панель показывает уже другую неделю. Строки привязаны к + аккаунту и при смене рисуются сразу на своих местах. + ## [0.2.2] — 2026-08-31 -### Исправлено +### Исправлено - **Обновление больше не оставляет приложение мёртвым.** 0.2.1 из релиза падала при первой же отрисовке панели: знак Claude в переключателе аккаунтов diff --git a/Sources/ClaudeWeekApp/PopoverView.swift b/Sources/ClaudeWeekApp/PopoverView.swift index 839ba4a..6fb411b 100644 --- a/Sources/ClaudeWeekApp/PopoverView.swift +++ b/Sources/ClaudeWeekApp/PopoverView.swift @@ -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 diff --git a/Sources/ClaudeWeekCore/LocalProvider.swift b/Sources/ClaudeWeekCore/LocalProvider.swift index 91696b6..fa1fd1e 100644 --- a/Sources/ClaudeWeekCore/LocalProvider.swift +++ b/Sources/ClaudeWeekCore/LocalProvider.swift @@ -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?] /// Чем именно потрачено — от дорогого к дешёвому. @@ -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, @@ -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 @@ -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 { @@ -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 @@ -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, diff --git a/Sources/ClaudeWeekCore/OfficialProvider.swift b/Sources/ClaudeWeekCore/OfficialProvider.swift index f3edf39..9e2fce3 100644 --- a/Sources/ClaudeWeekCore/OfficialProvider.swift +++ b/Sources/ClaudeWeekCore/OfficialProvider.swift @@ -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 } @@ -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.. [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 } @@ -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 } /// Факт в шкале ряда: какая доля остатка, что был к началу недели, съедена diff --git a/Sources/ClaudeWeekCore/WeekWindow.swift b/Sources/ClaudeWeekCore/WeekWindow.swift index 66564c3..054360c 100644 --- a/Sources/ClaudeWeekCore/WeekWindow.swift +++ b/Sources/ClaudeWeekCore/WeekWindow.swift @@ -280,10 +280,20 @@ public struct WeekWindow: Sendable, Equatable { public func rowSlots(at date: Date) -> Range { guard splitsResetDay else { return 0..= 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` это подряд идущие сутки от сброса — как diff --git a/Sources/ClaudeWeekTests/LocalProviderTests.swift b/Sources/ClaudeWeekTests/LocalProviderTests.swift index 4ea52a1..f34bb81 100644 --- a/Sources/ClaudeWeekTests/LocalProviderTests.swift +++ b/Sources/ClaudeWeekTests/LocalProviderTests.swift @@ -394,5 +394,7 @@ func runLocalProviderTests(_ t: Harness) async { t.close(after.totalCost, 5, "и стоимость считается по ней одной") t.close(after.costByDay.compactMap { $0 }.reduce(0, +), 5, "суточные полосы тоже без прежнего расхода") + t.close(after.costBeforeCount, 5, "но прежний расход помнится отдельно") + t.close(after.grossCost, 10, "вся стоимость окна — обе стороны отсечки") } } diff --git a/Sources/ClaudeWeekTests/OfficialProviderTests.swift b/Sources/ClaudeWeekTests/OfficialProviderTests.swift index 8c27f3a..49671fc 100644 --- a/Sources/ClaudeWeekTests/OfficialProviderTests.swift +++ b/Sources/ClaudeWeekTests/OfficialProviderTests.swift @@ -88,12 +88,21 @@ private struct TranscriptSandbox { /// Одно сообщение Opus заданной условной стоимости: вход $5 за Mtok. func write(cost: Double, at timestamp: String) { - let tokens = Int(cost / 5 * 1_000_000) - let line = """ - {"type":"assistant","uuid":"\(UUID().uuidString)","timestamp":"\(timestamp)",\ - "message":{"model":"claude-opus-5","usage":{"input_tokens":\(tokens),"output_tokens":0}}} - """ - try? (line + "\n").write( + write([(cost, timestamp)]) + } + + /// Несколько сообщений одним файлом: расход по разные стороны отсечки + /// счёта проверяется только вместе — по одному на прогон о нём ничего + /// не скажешь. + func write(_ entries: [(cost: Double, at: String)]) { + let lines = entries.map { entry in + let tokens = Int(entry.cost / 5 * 1_000_000) + return """ + {"type":"assistant","uuid":"\(UUID().uuidString)","timestamp":"\(entry.at)",\ + "message":{"model":"claude-opus-5","usage":{"input_tokens":\(tokens),"output_tokens":0}}} + """ + } + try? (lines.joined(separator: "\n") + "\n").write( to: root.appendingPathComponent("сессия.jsonl"), atomically: true, encoding: .utf8 ) } @@ -389,6 +398,59 @@ func runOfficialProviderTests(_ t: Harness) async { "по 2 % бюджет не перетирается — держим прежний", tolerance: 1e-9) } + // Сброс счёта отсекает прежний расход от локальной оценки — но не от + // недельного итога: сервер считает всю неделю целиком. Раньше форма в + // этом случае пропадала совсем, и панель показывала прочерки во всех + // сутках при непустой неделе. + await t.suite("форма недели: расход до отсечки не пропадает") { + let sandbox = TranscriptSandbox() + defer { sandbox.cleanup() } + // Две одинаковые траты по разные стороны отсечки: половина недели + // накоплена до сброса счёта, половина — после. + sandbox.write([ + (cost: 10, at: "2026-08-01T10:00:00.000Z"), + (cost: 10, at: "2026-08-04T06:00:00.000Z"), + ]) + try Store.saveState( + CountingState(countFrom: ISO8601.parse("2026-08-03T00:00:00.000Z")), + to: sandbox.stateURL + ) + + let snapshot = try await ResolvingProvider( + config: config(), + credentials: FakeCredentials(), + transport: FakeTransport(answers: [(200, realResponse)]), + cacheURL: sandbox.cacheURL, + localRoot: sandbox.root, + indexURL: sandbox.indexURL, + stateURL: sandbox.stateURL, + alertsURL: sandbox.alertsURL, + clock: { testNow } + ).fetch() + + // До отсечки в окне 34 рабочих часа: вечер пятницы (8), суббота и + // воскресенье (по 13). Ночь понедельника до 04:00 рабочей не считается, + // так что весь досчётный кусок разложен по этим 34 часам — и приходит + // к своим 25 % ровно в сутках отсечки. + t.close(snapshot.usedPercent, 50, "итог недели — от сервера, отсечка его не трогает") + t.close(snapshot.byDay[0].usedPercent ?? -1, 25 * 8 / 34, + "досчётный расход идёт вдоль плана, а не горкой в первых сутках", tolerance: 1e-9) + t.close(snapshot.byDay[1].usedPercent ?? -1, 25 * 21 / 34, + "к концу субботы — своя доля тех же 25 %", tolerance: 1e-9) + t.close(snapshot.byDay[2].usedPercent ?? -1, 25, + "в сутках отсечки досчётный кусок исчерпан", tolerance: 1e-9) + t.close(snapshot.byDay[3].usedPercent ?? -1, 25, + "дальше он просто тянется: значения накопительные", tolerance: 1e-9) + t.close(snapshot.byDay[4].usedPercent ?? -1, 50, + "свой расход добавился в свои сутки", tolerance: 1e-9) + t.check(snapshot.byDay[5].usedPercent == nil, "будущие сутки по-прежнему без факта") + + // Ровный темп плана перерасхода дать не может — а горкой в первых + // сутках он зажигал ⚠ в строке, где ничего не случилось. + t.check(snapshot.byDay.prefix(3).allSatisfy { $0.overspendPercent == 0 }, + "разложенный по плану расход перерасхода не рисует") + } + await t.suite("снимок официального источника") { let (official, _) = provider() let snapshot = try await official.fetch() diff --git a/Sources/ClaudeWeekTests/PlanTests.swift b/Sources/ClaudeWeekTests/PlanTests.swift index 1e567b3..7a9163e 100644 --- a/Sources/ClaudeWeekTests/PlanTests.swift +++ b/Sources/ClaudeWeekTests/PlanTests.swift @@ -230,4 +230,28 @@ func runPlanTests(_ t: Harness) { t.equal(snapshot.rows(at: at(2026, 8, 14, 9, 0)).last?.usedPercent, nil, "факт последней строки берётся у её же суток") } + + // Сброс среди ночи: последние сутки окна лежат вне рабочих часов, строки + // им не достаётся — но расход в них настоящий, и потеряться он не должен. + t.suite("строки дней: расход последних суток без строки") { + let night = WeekWindow(containing: at(2026, 8, 12, 12, 0), config: config(weekday: 3, hour: 2)) + let now = at(2026, 8, 18, 0, 30) + let snapshot = UsageSnapshot.make( + usedPercent: 60, + cumulativeByDay: [5, 15, 25, 35, 45, 50, 55, 60], + window: night, + source: .local, + fetchedAt: now, + isEstimate: true + ) + let rows = snapshot.rows(at: now) + t.equal(rows.map(\.index), [0, 1, 2, 3, 4, 5, 6], "ночной огрызок в ряд не попал") + t.close(rows.last?.usedPercent ?? 0, 60, + "и его расход долит в последнюю строку — до недельного итога") + t.close(rows[5].usedPercent ?? 0, 50, "остальные строки считают по своим суткам") + + // До полуночи доливать нечего: огрызок ещё не начался. + t.close(snapshot.rows(at: at(2026, 8, 17, 23, 0)).last?.usedPercent ?? 0, 55, + "пока сутки огрызка не наступили, последняя строка своя") + } } diff --git a/Sources/ClaudeWeekTests/WeekWindowTests.swift b/Sources/ClaudeWeekTests/WeekWindowTests.swift index 2d470d4..830ad0b 100644 --- a/Sources/ClaudeWeekTests/WeekWindowTests.swift +++ b/Sources/ClaudeWeekTests/WeekWindowTests.swift @@ -79,6 +79,37 @@ func runWeekWindowTests(_ t: Harness) { "номера суток сохраняются") } + // Сброс среди ночи: огрызок последних суток лежит вне рабочих часов и + // плана не набирает вовсе. Пока он вставал в ряд, две нижние строки + // показывали по 100 % — одну и ту же сотню дважды. + t.suite("окно недели: сброс среди ночи") { + // Вторник (weekday 3), сброс в 02:00, рабочий день 11–24. + let window = WeekWindow(containing: at(2026, 8, 12, 12, 0), config: config(weekday: 3, hour: 2)) + t.equal(window.start, at(2026, 8, 11, 2, 0), "старт — вторник 02:00") + t.check(window.splitsResetDay, "вторник разрезан надвое") + + let labels: (Date) -> [String] = { moment in + window.rows(at: moment).map { Formatting.weekdayShort($0.start, calendar: window.calendar) } + } + t.equal(labels(at(2026, 8, 18, 0, 30)), ["ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС", "ПН"], + "после полуночи ряд не переезжает на ночной огрызок") + t.equal(window.rows(at: at(2026, 8, 18, 0, 30)).last?.index, 6, + "последняя строка — понедельник, а не два часа перед сбросом") + t.equal(window.highlightedRow(at: at(2026, 8, 18, 0, 30)), 0, + "сегодняшний вторник подсвечен своей первой половиной") + + let plan = window.rows(at: at(2026, 8, 18, 0, 30)).map { Int($0.planPercent.rounded()) } + t.equal(plan, [14, 29, 43, 57, 71, 86, 100], "ряд приходит к 100 % ровно один раз") + + // Круглые сутки — огрызок свой вес имеет, и правило его не трогает. + let allDay = WeekWindow( + containing: at(2026, 8, 12, 12, 0), + config: config(weekday: 3, hour: 2, work: WorkHours(start: 0, end: 24)) + ) + t.equal(allDay.rows(at: at(2026, 8, 18, 0, 30)).last?.index, 7, + "при круглосуточном плане последние сутки в ряду остаются") + } + // Неделя, начинающаяся понедельником: ряд повёрнут к нему, а сутки окна, // прошедшие до понедельника, стоят под текущей неделей — они уже позади. t.suite("окно недели: начало в понедельник") { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6af1b89..380c065 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,7 +116,15 @@ SwiftUI` — значит, расчёт просочился в UI или нао аккаунте, и маркера аккаунта в записях нет — отсечка по времени единственное, чем расход прежнего аккаунта отделяется от нынешнего. Перечитывается она на каждом обходе, а не запоминается в поле: сброс - приходит из окна настроек, когда провайдер давно создан. + приходит из окна настроек, когда провайдер давно создан. Из **счёта** + до-отсечный расход выброшен, но не из **формы**: `LocalUsage.costBeforeCount` + помнит его отдельно, и `OfficialProvider.shapeByDay` раскладывает его по + плану отрезка «начало окна → отсечка» (`carriedShares`). Итог недели сервер + считает целиком, отсечки не зная, — без этого ряд стоял бы пустым + (`— / 43 %` во всех сутках) при непустой неделе, а футер показывал бы свои + проценты. Ровный темп в рабочие часы — та же догадка, по которой считается + план: подённой правды об этом расходе нет, а сложенный горкой в одни сутки, + он зажигал в них перерасход, которого не было. 8. **`LocalProvider.scan` всегда идёт по диску.** Кешировать его итог нельзя, даже когда за цикл его зовут дважды: дописанная строка транскрипта потерялась бы до следующего запроса. @@ -135,7 +143,13 @@ SwiftUI` — значит, расчёт просочился в UI или нао - `.reset` — ряд от суток сброса, как неделя катится. Показывается та половина дня сброса, что идёт сейчас: до полуночи последних суток окна это вечер после сброса (первая строка), дальше — утро перед следующим - (последняя, 100 %). + (последняя, 100 %). Переезд не случается, когда у последних суток нет + рабочих часов вовсе (сброс среди ночи: окно кончается в 02:00, план + растёт с 11:00): их строка повторила бы предыдущую теми же 100 % — + план в ней не набирается нисколько. Тогда день сброса остаётся в ряду + вечерней половиной, а расход суток, оставшихся без строки, доливается + в последнюю строку ряда (`UsageSnapshot.rows(at:)`): показать его больше + негде, а разойтись с итогом в футере ряд не должен. - `.monday` (по умолчанию) — ряд повёрнут к понедельнику, и половина дня сброса выбирается не моментом, а местом в ряду: утро перед следующим сбросом, чтобы план дорастал до 100 % в его строке. Сутки, прошедшие до diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index eb7abd7..45af6b9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -16,7 +16,7 @@ - [Ограничения, с которыми живём](#ограничения-с-которыми-живём) — 9–10 - [Что сознательно не делаем](#что-сознательно-не-делаем) - [Что закрыл аудит](#что-закрыл-аудит-2026-08-05) — журналы 2026-08-05, 06 и 07 -- [Что сделано после аудита](#что-сделано-после-аудита) — журналы 2026-08-08, 10, 15, 16, 17 и 31 +- [Что сделано после аудита](#что-сделано-после-аудита) — журналы 2026-08-08, 10, 15, 16, 17, 31 и 2026-09-01 --- @@ -327,3 +327,11 @@ requirement не менялся и приложение держалось в с | виджет показывал ровно один аккаунт — тот, под которым сейчас работает Claude Code, — и поставить рядом рабочий с личным было нельзя. Прежние цифры при переключении не сохранялись, а отбрасывались (бывший пункт 10) | два аккаунта в панели. Claude Code различает их конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит свой токен, не выбивая первый. Появился каталог — в заголовке встают два знака Claude; выбранный горит оранжевым, второй гаснет до серого, а кто из них кто, говорит подсказка с живым адресом из `claude auth status`. Состояние хранится по аккаунту, а не одним файлом: у второго свои `secondary-cache.json`, `secondary-alerts.json`, `secondary-state.json` и `secondary-index.json`, поэтому обратное переключение мгновенно и ничего не теряет. Имя записи Keychain второго дома Claude Code не публикует, но собирает его из пути дома (`Claude Code-credentials-<первые 8 hex sha256 пути>`) — ClaudeWeek повторяет ту же формулу, и сторожит её тест с живым вектором. Искать запись по `organizationUuid` внутри неё, как было задумано сперва, больше нельзя: 2.1.251 убрал это поле из записи, и принадлежность токена теперь называет только `claude auth status`. Каталога нет — нет и переключателя. Осталось за бортом: аккаунтов ровно два, произвольного числа нет — под него пришлось бы заводить список в конфиге, а спроса на третий пока не видно | | приложение из релиза 0.2.1 падало через секунду после запуска, и обновление выглядело как «клад падает и не обновляется»: образ вставал успешно, но новая версия умирала, а в строке меню оставалась прежняя — и назавтра всё повторялось | знак Claude читается через `Bundle.main` из `Contents/Resources`, а не через `Bundle.module`: генерируемый SwiftPM аксессор ищет ресурсный бандл в корне `.app` и по абсолютному пути в `.build` машины сборки, поэтому у собиравшего работал, а у всех остальных — `fatalError`. `make-app.sh` теперь проверяет, что каждый ресурс лёг туда, откуда его возьмёт `Bundle.main`, и не собирает бандл, если `Bundle.module` вернётся в код приложения: прежняя проверка убеждалась лишь в том, что ресурсный бандл есть в `.build`, — он был на месте всё то время, пока приложение падало. Пропавшая картинка теперь заменяется вопросительным знаком, как и задумано, а не роняет процесс | | `claude auth status` спрашивали всегда с `CLAUDE_CONFIG_DIR`, и вкладка «Доступ» показывала «не вошли» у аккаунта, в котором человек работает прямо сейчас | переменная ставится только для нестандартного дома. Выставленная даже в тот же самый путь `~/.claude`, она заставляет Claude Code отвечать `loggedIn: false` — ячейку учётных данных выбирает не путь, а факт установки переменной (2.1.251). Унаследованная из окружения снимается: приложение могли запустить из оболочки под вторым аккаунтом. Грабли записаны в [ARCHITECTURE.md](ARCHITECTURE.md#грабли) | + +### 2026-09-01 + +| Было | Стало | +|---|---| +| нижние две строки ряда приходили к 100 % обеими сразу, а расход последних часов недели пропадал вместе со строкой, которой им не досталось | при сбросе среди ночи последние сутки окна — огрызок в пару часов вне рабочих часов: плана он не набирает нисколько и повторял сотню предыдущих суток. Такой огрызок своей строки больше не получает (`WeekWindow.rowSlots`), день сброса остаётся в ряду первой половиной, а расход, случившийся в нём, доливается в последнюю строку (`UsageSnapshot.rows(at:)`) — иначе ряд заканчивался бы числом меньше итога в футере. Там, где у последних суток рабочие часы есть, всё как было | +| после сброса счёта суточные полосы пустели целиком: `— / 43 %` во всех сутках при непустой неделе, а футер показывал свои проценты | отсечка убирает прежний расход из локального счёта, но не из недельного итога — его считает сервер, который об отсечке не знает. Теперь до-отсечный расход помнится отдельно (`LocalUsage.costBeforeCount`) и раскладывается по дням тем же ровным темпом, каким считается план, — от начала окна до момента сброса (`carriedShares`). Ряд идёт вдоль плановой линии и приходит к общему проценту в сутках сброса; горкой в один день этот расход не сваливается намеренно — в тех сутках загорался бы перерасход, которого не было. На локальную оценку это не влияет: там отсечка работает по-прежнему | +| переключение аккаунта проигрывалось анимацией: полосы уезжали в ноль и приползали на новые места, будто это те же цифры изменились | строки привязаны к аккаунту (`.id(activeAccount)` в `PopoverView`) и при смене рисуются сразу на своих местах. У другого аккаунта другая неделя, а не изменившаяся своя; обычное обновление данных анимируется как прежде | diff --git a/docs/USAGE.md b/docs/USAGE.md index c1dcc6e..71fccc6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -886,6 +886,15 @@ Keychain недоступен: без него метку не прочитат итог недели сервер и так считает по нынешнему аккаунту. Офлайн отсечка важнее — локальная оценка целиком построена на транскриптах. +Сами полосы после сброса не пустеют. Расход до отсечки сервер уже насчитал, и +показать его надо — но подённой правды о нём нет: тем и занята отсечка, чтобы +прежний счёт не смешивался с нынешним. Поэтому он раскладывается по дням тем же +ровным темпом, каким считается план, — от начала недели до момента сброса. Ряд +идёт вдоль плановой линии и приходит к общему проценту ровно в сутках сброса, а +всё, что потрачено после, добавляется к нему своими сутками. Горкой в один день +этот расход не сваливается намеренно: в тех сутках зажигался бы перерасход, +которого не было. + ## Командная строка ```