diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb31ef..9e58990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,36 @@ ## [Не выпущено] +### Добавлено + +- Настройка **«Начало недели»**: ряд суток на панели идёт либо с понедельника + (по умолчанию), либо с дня сброса, как раньше. При понедельнике у ряда своя + шкала: понедельник открывается нулём, пятница приходит к 100 % в момент + сброса. 100 % здесь — не весь недельный лимит, а то, что осталось к утру + понедельника: по нему раскладывается план, от него же считается факт. + Суббота с воскресеньем, прошедшие сразу после сброса, уходят под черту в + конец ряда и остаются в недельных процентах — они и правда съели свою долю + лимита. Итог недели в футере тоже недельный. Границы окна и момент сброса + настройка не трогает. + +### Изменено + +- Заводская таймзона — системная, а не `Europe/Saratov`. Зона, в которой + писалась программа, попадала на чужую машину дефолтом, и сутки на панели + переключались там не в местную полночь. У кого зона уже выбрана руками, + ничего не меняется. +- Полей «День / час / минута сброса» в настройках больше нет. Момент сброса + приходит от сервера (`resets_at`), а офлайн проецируется от последнего, им + же названного, — поля не влияли ни на что и читались как сломанные: их + крутили, ждали, что неделя сдвинется, и не понимали, почему ничего не + меняется. Вместо них под «Началом недели» показан настоящий момент сброса в + выбранной зоне. В `config.json` ключи остались: по ним строится окно, если + сервер не отвечал ещё ни разу и кеш пуст. + ### Исправлено +- В строке состояния на вкладке «О программе» больше нет «у вас»: просто + «последняя версия — 0.1.12, проверено в 13:17». - Разовый сбой GitHub API больше не отменяет выпуск. Список файлов PR спрашивается с тремя попытками: 17 августа GitHub был в частичной аварии и ответил на этот запрос `HTTP 503` ровно в момент слияния — шаг упал, и версия diff --git a/README.md b/README.md index 35401d9..acf66cb 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ Code сбрасывает каждым обновлением токена, и - **Неделя с планом** — семь суточных полос: факт, плановая зона, перерасход. 100 % лимита раскладываются по **рабочим часам** (по умолчанию 11:00 → 00:00), а не по астрономическим: сон не должен весить столько же, сколько работа. + Ряд идёт с понедельника, а сутки, прошедшие сразу после сброса, стоят под + чертой в конце; в настройках его можно развернуть от дня сброса. - **Пятичасовую сессию** — отдельной строкой над сутками, с часом сброса. Тот лимит, в который упираются чаще недельного. - **Прогноз** — «при таком темпе кончится ПТ 15:57», тоже по рабочим часам. diff --git a/Sources/ClaudeWeekApp/CLI.swift b/Sources/ClaudeWeekApp/CLI.swift index d50f3b0..4813b20 100644 --- a/Sources/ClaudeWeekApp/CLI.swift +++ b/Sources/ClaudeWeekApp/CLI.swift @@ -288,10 +288,8 @@ enum CLI { // он лежит в кеше, а не в конфиге. let budget = try? await local.budget(for: usage, override: Store.loadCache()?.weeklyBudget) var percent: Output.Percent? - var cumulative: [Double?] = usage.costByDay.map { _ in nil } if let snapshot { - cumulative = snapshot.byDay.map(\.usedPercent) let metrics = snapshot.metrics(at: now, thresholds: config.thresholds) percent = Output.Percent( used: metrics.usedPercent, @@ -316,18 +314,29 @@ enum CLI { ) } - // Те же семь строк, что и на панели: день сброса одной, той его - // половиной, что идёт сейчас. `index` — номер суток окна, поэтому + // Те же семь строк, что и на панели: день сброса одной, а у ряда от + // понедельника — своя шкала процентов. Считает их снимок, чтобы вывод + // сходился с панелью до цифры; без снимка остаётся голое окно, и факт + // в строках взять неоткуда. `index` — номер суток окна, поэтому // у последней пятницы он седьмой, а не шестой. - let days = window.rows(at: now).map { slot in + let rows: [(index: Int, start: Date, plan: Double, used: Double?, partial: Bool)] = + snapshot.map { snapshot in + snapshot.rows(at: now).map { + ($0.index, $0.start, $0.planPercent, $0.usedPercent, $0.isPartial) + } + } ?? window.rows(at: now).map { + ($0.index, $0.start, $0.planPercent, nil, $0.isPartial) + } + + let days = rows.map { row in Output.Day( - index: slot.index, - label: Formatting.weekdayShort(slot.start, calendar: window.calendar), - start: slot.start, - planPercent: slot.planPercent, - usedPercent: slot.index < cumulative.count ? cumulative[slot.index] : nil, - cost: slot.index < usage.costByDay.count ? usage.costByDay[slot.index] : nil, - partial: slot.isPartial + index: row.index, + label: Formatting.weekdayShort(row.start, calendar: window.calendar), + start: row.start, + planPercent: row.plan, + usedPercent: row.used, + cost: row.index < usage.costByDay.count ? usage.costByDay[row.index] : nil, + partial: row.partial ) } diff --git a/Sources/ClaudeWeekApp/PanelModel.swift b/Sources/ClaudeWeekApp/PanelModel.swift index 65aec57..3184d59 100644 --- a/Sources/ClaudeWeekApp/PanelModel.swift +++ b/Sources/ClaudeWeekApp/PanelModel.swift @@ -104,10 +104,13 @@ final class PanelModel { config.thresholds.colorizeMenuBar } - /// Номер текущих суток окна — их строку подсвечиваем. Совпадает с + /// Номер суток, чью строку подсвечиваем как сегодняшнюю. Совпадает с /// `DayUsage.index`: строки нумеруются сутками окна, а не позицией в ряду. + /// Считает окно, а не панель: в день сброса в ряд попадает лишь одна из + /// двух его половин, и «сегодня» ищется по дню недели (см. + /// `WeekWindow.highlightedRow(at:)`). var todayIndex: Int? { - snapshot?.window.dayIndex(for: now) + snapshot?.window.highlightedRow(at: now) } /// Показывать весь недельный ряд: так настроено или так раскрыли кликом. diff --git a/Sources/ClaudeWeekApp/PopoverView.swift b/Sources/ClaudeWeekApp/PopoverView.swift index 6e2e192..ca7cff4 100644 --- a/Sources/ClaudeWeekApp/PopoverView.swift +++ b/Sources/ClaudeWeekApp/PopoverView.swift @@ -195,11 +195,19 @@ struct PopoverView: View { private func days(_ snapshot: UsageSnapshot) -> some View { let calendar = snapshot.window.calendar + let rows = model.dayRows(snapshot) // Строк семь — или одна, текущих суток, если панель настроена // компактно. День сброса стоит одной: пока неделя катится — её вечером // после сброса, в последние сутки окна — утром перед следующим. return VStack(spacing: Theme.rowSpacing) { - ForEach(model.dayRows(snapshot), id: \.index) { day in + ForEach(Array(rows.enumerated()), id: \.element.index) { position, day in + // Неделя, начатая понедельником, кончается сутками, прошедшими + // ещё до него, — у них номер меньше, чем у строки выше. Черта + // отделяет этот хвост: без неё выходные читались бы как + // продолжение недели, а их проценты — как обвал плана. + if position > 0, day.index < rows[position - 1].index { + Divider().overlay(palette.separator.color) + } DayRow( day: day, label: Formatting.weekdayShort(day.start, calendar: calendar, lang: s.lang), diff --git a/Sources/ClaudeWeekApp/Screenshot.swift b/Sources/ClaudeWeekApp/Screenshot.swift index ac76f2d..3b28ae8 100644 --- a/Sources/ClaudeWeekApp/Screenshot.swift +++ b/Sources/ClaudeWeekApp/Screenshot.swift @@ -63,6 +63,9 @@ enum Screenshot { // рабочий день оставляем из конфига: от них зависят подписи строк. config.appearance = AppearanceConfig() config.thresholds = Thresholds() + // Порядок строк — тоже вид, а не окно: картинки должны показывать + // заводскую неделю от понедельника, чей бы конфиг ни лежал рядом. + config.weekStart = Config.default.weekStart // Единственное отступление от заводского вида: недельный ряд. Из // коробки панель компактная, но картинка в README должна показывать // то, ради чего программа написана, — все семь суток разом. Как diff --git a/Sources/ClaudeWeekApp/SettingsView.swift b/Sources/ClaudeWeekApp/SettingsView.swift index 8bd6766..e221a22 100644 --- a/Sources/ClaudeWeekApp/SettingsView.swift +++ b/Sources/ClaudeWeekApp/SettingsView.swift @@ -113,38 +113,20 @@ private struct GeneralSettings: View { } Section(s.pick("Недельное окно", "Week window")) { - Picker(s.pick("День сброса", "Reset day"), selection: config.resetWeekday) { - ForEach(Array(Formatting.weekdayNames(s.lang).enumerated()), id: \.offset) { index, name in - Text(name).tag(index + 1) + Picker(s.pick("Начало недели", "Week starts on"), selection: config.weekStart) { + ForEach(WeekStart.allCases, id: \.self) { start in + Text(start.title(s.lang)).tag(start) } } - HStack { - Stepper( - s.pick("Час сброса: \(model.config.resetHour)", "Reset hour: \(model.config.resetHour)"), - value: config.resetHour, - in: 0...23 - ) - Stepper( - s.pick("Минута: \(model.config.resetMinute)", "Minute: \(model.config.resetMinute)"), - value: config.resetMinute, - in: 0...59 - ) - } Picker(s.pick("Таймзона", "Time zone"), selection: config.timeZone) { Text(s.pick("Системная", "System")).tag("") ForEach(popularZones, id: \.self) { zone in Text(zone).tag(zone) } } - Text(s.pick(""" - При живом официальном источнике момент сброса берётся из ответа \ - сервера — эти поля нужны только офлайн. - """, """ - While the official source is alive, the reset moment comes from the \ - server — these fields matter offline only. - """)) - .font(.caption) - .foregroundStyle(.secondary) + Text(model.weekStartNote) + .font(.caption) + .foregroundStyle(.secondary) } Section(s.pick("Рабочий день", "Working day")) { diff --git a/Sources/ClaudeWeekApp/SettingsWindow.swift b/Sources/ClaudeWeekApp/SettingsWindow.swift index 54d7a1b..eec6e76 100644 --- a/Sources/ClaudeWeekApp/SettingsWindow.swift +++ b/Sources/ClaudeWeekApp/SettingsWindow.swift @@ -27,6 +27,11 @@ final class SettingsModel { /// в кеше, а не в конфиге, поэтому вкладка «О программе» читает его /// отдельно — иначе она показывала бы «не подобран» даже после калибровки. private(set) var pickedBudget: Double? + /// Момент недельного сброса, названный сервером. Живёт в кеше, а не в + /// конфиге: настройками его не задать — и именно поэтому его нужно + /// показать. Человек, искавший в настройках «день сброса», иначе крутит + /// поля и гадает, почему неделя начинается не тогда, когда он поставил. + private(set) var officialReset: Date? /// Автозапуск тоже мимо конфига: он и есть launchd-агент, и правда о нём /// одна — лежит плист или нет. private(set) var launchAtLogin = LoginItem.isEnabled @@ -62,7 +67,11 @@ final class SettingsModel { /// перерисовку вкладки незачем, а между показами плист мог снести /// `uninstall.sh` или рука. func refreshDiagnostics() { - pickedBudget = Store.loadCache()?.weeklyBudget + let cache = Store.loadCache() + pickedBudget = cache?.weeklyBudget + // Момент из кеша мог остаться в прошлой неделе — проекция вперёд + // целыми неделями даёт тот, который наступит следующим. + officialReset = cache?.projectedWindow(at: Date(), config: config)?.end launchAtLogin = LoginItem.isEnabled // Разрешение на уведомления снимают там же, где выдали, — в системных // настройках, мимо этого окна. Спрашиваем систему на каждый показ. @@ -103,6 +112,25 @@ final class SettingsModel { return s.pick("не подобран", "not worked out yet") } + /// Подсказка под «Началом недели»: настройка двигает только порядок строк + /// на панели, а сам момент сброса приходит от сервера — тот, что здесь и + /// показан, в той же зоне, по которой панель считает сутки. + var weekStartNote: String { + let s = config.strings + guard let officialReset else { + return s.pick( + "Момент сброса задаёт сервер, а не эта настройка: она двигает только порядок строк на панели.", + "The reset moment comes from the server, not from this setting: it only shifts the row order." + ) + } + let day = Formatting.weekdayShort(officialReset, calendar: config.calendar, lang: s.lang) + let time = Formatting.clock(officialReset, calendar: config.calendar) + return s.pick( + "Сброс задаёт сервер — \(day) \(time) по выбранной зоне. Эта настройка двигает только порядок строк.", + "The reset comes from the server — \(day) \(time) in the zone picked above. This setting only shifts the row order." + ) + } + /// «Показать пример» с вкладки уведомлений: тот же баннер, что придёт /// по-настоящему, — по одному на каждый лимит. Идёт мимо конфига: /// показанному баннеру нечего сохранять. diff --git a/Sources/ClaudeWeekApp/StatusItemController.swift b/Sources/ClaudeWeekApp/StatusItemController.swift index 4433549..805e5ce 100644 --- a/Sources/ClaudeWeekApp/StatusItemController.swift +++ b/Sources/ClaudeWeekApp/StatusItemController.swift @@ -279,9 +279,7 @@ final class StatusItemController: NSObject { private func applyFromSettings(_ config: Config) { let providerChanged = config.provider != model.config.provider || config.timeZone != model.config.timeZone - || config.resetWeekday != model.config.resetWeekday - || config.resetHour != model.config.resetHour - || config.resetMinute != model.config.resetMinute + || config.weekStart != model.config.weekStart || config.workHours != model.config.workHours || config.weeklyBudget != model.config.weeklyBudget // Интервал живёт в таймере, а не в провайдере: не перезапустив таймер, diff --git a/Sources/ClaudeWeekApp/UpdateController.swift b/Sources/ClaudeWeekApp/UpdateController.swift index edc862e..2cc8dcc 100644 --- a/Sources/ClaudeWeekApp/UpdateController.swift +++ b/Sources/ClaudeWeekApp/UpdateController.swift @@ -349,8 +349,8 @@ final class UpdateController { case .checking: strings.pick("спрашиваю GitHub…", "asking GitHub…") case .upToDate(let at): - strings.pick("у вас последняя — \(ClaudeWeek.version), проверено \(Self.checkedAt(at, lang: strings.lang))", - "you are on the latest — \(ClaudeWeek.version), checked \(Self.checkedAt(at, lang: strings.lang))") + strings.pick("последняя версия — \(ClaudeWeek.version), проверено \(Self.checkedAt(at, lang: strings.lang))", + "latest version — \(ClaudeWeek.version), checked \(Self.checkedAt(at, lang: strings.lang))") case .available(let release): strings.pick("доступна \(release.version) — у вас \(ClaudeWeek.version)", "\(release.version) is available — you have \(ClaudeWeek.version)") diff --git a/Sources/ClaudeWeekCore/Config.swift b/Sources/ClaudeWeekCore/Config.swift index 95eec70..90a19aa 100644 --- a/Sources/ClaudeWeekCore/Config.swift +++ b/Sources/ClaudeWeekCore/Config.swift @@ -265,8 +265,39 @@ public struct Thresholds: Codable, Sendable, Equatable { } } +/// С какого дня недели идёт ряд суток на панели. +/// +/// К моменту сброса это отношения не имеет: он приходит из `resets_at` +/// официального ответа, а офлайн проецируется от последнего названного +/// сервером — настройками его не сдвинуть. Здесь только порядок строк. +public enum WeekStart: String, Codable, Sendable, CaseIterable { + /// Ряд начинается с понедельника — так, как неделя устроена у человека. + /// Сутки окна, прошедшие до него (выходные сразу после сброса), уходят + /// в конец ряда: они уже позади, и место им внизу. + case monday + /// Ряд начинается с суток сброса — так неделя катится на самом деле, + /// и накопительный план растёт сверху вниз ровно до 100 %. + case reset + + public func title(_ lang: Lang) -> String { + let l = L10n(lang) + switch self { + case .monday: return l.pick("Понедельник", "Monday") + case .reset: return l.pick("День сброса", "Reset day") + } + } +} + public struct Config: Codable, Sendable, Equatable { + /// С какого дня недели ряд суток на панели. На цифры не влияет: сдвигается + /// только порядок строк. + public var weekStart: WeekStart /// 1 = воскресенье … 6 = пятница … 7 = суббота (нумерация Calendar). + /// + /// Догадка последней надежды: окно строится по ней, только если сервер не + /// отвечал ещё ни разу и кеш пуст. Настройками не правится намеренно — + /// момент сброса задаёт сервер, и поле, которое почти никогда ни на что + /// не влияет, в настройках читалось как сломанное. public var resetWeekday: Int public var resetHour: Int public var resetMinute: Int @@ -302,14 +333,19 @@ public struct Config: Codable, Sendable, Equatable { public static let minimumRefreshInterval: TimeInterval = 30 /// Сброс недельного лимита приходит в 12:00 UTC — в 15:00 по Москве и в - /// 16:00 здесь, в UTC+4. Это догадка на случай, когда сервер молчит: в - /// обычной жизни момент берётся из `resets_at` официального ответа. Час - /// привязан к `timeZone` ниже — сменив её, поправьте и его. + /// 16:00 в UTC+4. Час здесь — догадка на случай, когда сервер молчит и + /// кеш пуст: в обычной жизни момент берётся из `resets_at` официального + /// ответа, а офлайн проецируется от последнего, названного сервером. + /// + /// Зона по умолчанию — системная: сутки на панели должны переключаться в + /// местную полночь у любого, кто поставил программу, а не в полночь той + /// зоны, где её писали. public static let `default` = Config( + weekStart: .monday, resetWeekday: 6, resetHour: 16, resetMinute: 0, - timeZone: "Europe/Saratov", + timeZone: "", refreshInterval: 300, provider: .auto, workHours: WorkHours.default, @@ -324,6 +360,7 @@ public struct Config: Codable, Sendable, Equatable { ) public init( + weekStart: WeekStart = .monday, resetWeekday: Int, resetHour: Int, resetMinute: Int, @@ -340,6 +377,7 @@ public struct Config: Codable, Sendable, Equatable { appearance: AppearanceConfig = AppearanceConfig(), language: Language = .system ) { + self.weekStart = weekStart self.resetWeekday = resetWeekday self.resetHour = resetHour self.resetMinute = resetMinute @@ -363,6 +401,7 @@ public struct Config: Codable, Sendable, Equatable { let c = try decoder.container(keyedBy: CodingKeys.self) let d = Config.default self.init( + weekStart: try c.decodeIfPresent(WeekStart.self, forKey: .weekStart) ?? d.weekStart, resetWeekday: try c.decodeIfPresent(Int.self, forKey: .resetWeekday) ?? d.resetWeekday, resetHour: try c.decodeIfPresent(Int.self, forKey: .resetHour) ?? d.resetHour, resetMinute: try c.decodeIfPresent(Int.self, forKey: .resetMinute) ?? d.resetMinute, diff --git a/Sources/ClaudeWeekCore/Formatting.swift b/Sources/ClaudeWeekCore/Formatting.swift index 855e4a4..664ae7f 100644 --- a/Sources/ClaudeWeekCore/Formatting.swift +++ b/Sources/ClaudeWeekCore/Formatting.swift @@ -29,12 +29,6 @@ public enum Formatting { return lang == .ru ? fullWeekdays[index] : fullWeekdaysEN[index] } - /// Семь названий подряд, начиная с воскресенья, — нумерация `Calendar`. - /// Нужны списку «День сброса» в настройках, где дни выбираются не по дате. - public static func weekdayNames(_ lang: Lang) -> [String] { - lang == .ru ? fullWeekdays : fullWeekdaysEN - } - public static func clock(_ date: Date, calendar: Calendar) -> String { let c = calendar.dateComponents([.hour, .minute], from: date) return String(format: "%d:%02d", c.hour ?? 0, c.minute ?? 0) diff --git a/Sources/ClaudeWeekCore/ResolvingProvider.swift b/Sources/ClaudeWeekCore/ResolvingProvider.swift index 341fc40..1c68f3b 100644 --- a/Sources/ClaudeWeekCore/ResolvingProvider.swift +++ b/Sources/ClaudeWeekCore/ResolvingProvider.swift @@ -168,7 +168,8 @@ public extension CachedUsage { start: windowStart, end: windowEnd, calendar: config.calendar, - workHours: config.workHours + workHours: config.workHours, + weekStart: config.weekStart ) // Разбивку берём только если она нарезана так же, как нынешнее окно. // Кеш прошлой версии считал сутки от сброса до сброса — наложенный на diff --git a/Sources/ClaudeWeekCore/Snapshot.swift b/Sources/ClaudeWeekCore/Snapshot.swift index 66ec82c..d7623e2 100644 --- a/Sources/ClaudeWeekCore/Snapshot.swift +++ b/Sources/ClaudeWeekCore/Snapshot.swift @@ -233,15 +233,44 @@ public struct UsageSnapshot: Sendable { ) } - /// Строки панели на момент `now`: семь суток, где день сброса представлен - /// той своей половиной, которая идёт сейчас. До полуночи последних суток - /// окна это вечер после сброса, дальше — утро перед следующим. + /// Строки панели на момент `now`: семь суток в порядке, который задаёт + /// `weekStart`, — от сброса или от понедельника. День сброса занимает + /// одну строку из двух своих половин (см. `WeekWindow.rowOrder(at:)`). + /// + /// У ряда, открытого понедельником, своя шкала: и план, и факт считаются + /// от того, что осталось к его утру, — понедельник начинается с нуля, + /// пятница приходит к 100 %. Сутки за скобкой остаются в недельной. public func rows(at now: Date) -> [DayUsage] { - window.rowSlots(at: now).compactMap { index in - byDay.indices.contains(index) ? byDay[index] : nil + let base = window.scaleBase(at: now) + return window.rowOrder(at: now).compactMap { index in + guard byDay.indices.contains(index) else { return nil } + let day = byDay[index] + guard let base, index >= base else { return day } + return DayUsage( + index: day.index, + start: day.start, + end: day.end, + planPercent: window.planPercent(forDay: index, from: base), + usedPercent: rebased(day.usedPercent, base: base), + isPartial: day.isPartial + ) } } + /// Факт в шкале ряда: какая доля остатка, что был к началу недели, съедена + /// к концу этих суток. Знаменатель здесь фактический, а не плановый: план + /// раскладывается по тому, что осталось на самом деле, и сравнивать его с + /// долей от нетронутого бюджета значило бы мерить их разными линейками. + private func rebased(_ used: Double?, base: Int) -> Double? { + guard let used else { return nil } + // Факт на начало недели — накопительный итог суток перед ней. + let spent = byDay.indices.contains(base - 1) ? (byDay[base - 1].usedPercent ?? 0) : 0 + let left = 100 - spent + // Лимит сожжён ещё до понедельника: делить нечего, вся неделя красная. + guard left > 0 else { return 100 } + return min(max((used - spent) / left * 100, 0), 100) + } + /// Тот же снимок с другой сессией. Нужна потому, что считает неделю один /// источник, а помнит сессию — другой: локальная оценка про пятичасовой /// лимит не знает ничего, и его подставляет тот, у кого есть кеш. diff --git a/Sources/ClaudeWeekCore/WeekWindow.swift b/Sources/ClaudeWeekCore/WeekWindow.swift index 6d2bc10..66564c3 100644 --- a/Sources/ClaudeWeekCore/WeekWindow.swift +++ b/Sources/ClaudeWeekCore/WeekWindow.swift @@ -33,6 +33,9 @@ public struct WeekWindow: Sendable, Equatable { public let calendar: Calendar /// Часы, в которые план растёт. Всё остальное время он стоит. public let workHours: WorkHours + /// С какого дня недели ложится ряд строк. Границы окна от этого не + /// двигаются: сброс задаёт сервер, а здесь только порядок. + public let weekStart: WeekStart /// Границы суток окна: старт окна, каждая местная полночь внутри него, /// конец окна. Отсюда и число суток, и их план. public let dayBounds: [Date] @@ -72,7 +75,8 @@ public struct WeekWindow: Sendable, Equatable { start: start, end: calendar.date(byAdding: .day, value: WeekWindow.daysInWeek, to: start) ?? start, calendar: calendar, - workHours: config.workHours + workHours: config.workHours, + weekStart: config.weekStart ) } @@ -88,7 +92,8 @@ public struct WeekWindow: Sendable, Equatable { start: calendar.date(byAdding: .day, value: -WeekWindow.daysInWeek, to: end) ?? end, end: end, calendar: calendar, - workHours: config.workHours + workHours: config.workHours, + weekStart: config.weekStart ) } @@ -96,12 +101,14 @@ public struct WeekWindow: Sendable, Equatable { start: Date, end: Date, calendar: Calendar, - workHours: WorkHours = WorkHours.default + workHours: WorkHours = WorkHours.default, + weekStart: WeekStart = .reset ) { self.start = start self.end = end self.calendar = calendar self.workHours = workHours + self.weekStart = weekStart self.dayBounds = WeekWindow.bounds(from: start, to: end, calendar: calendar) self.workTotal = workHours.seconds(from: start, to: end, calendar: calendar) } @@ -277,15 +284,105 @@ public struct WeekWindow: Sendable, Equatable { return offset..<(offset + rowCount) } + /// Номера суток окна в том порядке, в каком они лягут строками панели. + /// + /// При `weekStart == .reset` это подряд идущие сутки от сброса — как + /// неделя и катится. При `.monday` ряд повёрнут к понедельнику, и сутки + /// начала окна (выходные сразу после сброса) уезжают вниз: они уже + /// прошли, и место им под текущей неделей. + public func rowOrder(at date: Date) -> [Int] { + switch weekStart { + case .reset: return Array(rowSlots(at: date)) + case .monday: return mondayFirstOrder() + } + } + + /// Порядок строк от понедельника. Половина дня сброса здесь выбирается + /// не по текущему моменту, а по месту в ряду: когда неделя начинается не + /// в день сброса, ему достаётся утро перед следующим сбросом — тогда + /// план в его строке доходит ровно до 100 %, и ряд от понедельника до + /// дня сброса читается сверху вниз. Начнись неделя в сам день сброса — + /// наоборот, вечер после него, иначе ряд открывался бы сотней процентов. + private func mondayFirstOrder() -> [Int] { + var order = Array(0.. Bool { + calendar.component(.weekday, from: date) == 2 + } + + /// Первые сутки ряда, когда он живёт в своей шкале, — иначе nil. + /// + /// Неделя, начатая понедельником, отсчитывает план от него: строка + /// понедельника открывается нулём, пятница приходит к 100 % в момент + /// сброса. Ряд от дня сброса и ряд, который и так начинается сутками + /// сброса, живут в общей с окном шкале — перешкаливать там нечего. + public func scaleBase(at date: Date) -> Int? { + guard weekStart == .monday else { return nil } + guard let first = rowOrder(at: date).first, first > 0 else { return nil } + return first + } + + /// План к концу суток `index` в шкале ряда, открытого сутками `base`: + /// 0 в их полночь, 100 % в момент сброса. + /// + /// Считается заново по рабочим часам отрезка, а не сжатием недельного + /// плана: у выходных, оставшихся за скобкой, свой вес, и вычитать его + /// из общей шкалы значило бы гадать, сколько их часов «принадлежит» + /// рабочей неделе. + public func planPercent(forDay index: Int, from base: Int) -> Double { + guard dayBounds.indices.contains(base), dayBounds.indices.contains(index + 1) else { return 0 } + let from = dayBounds[base] + let total = workHours.seconds(from: from, to: end, calendar: calendar) + guard total > 0 else { return 0 } + let worked = workHours.seconds(from: from, to: dayBounds[index + 1], calendar: calendar) + return min(max(worked / total, 0), 1) * 100 + } + /// Строки панели на момент `date`. Номера суток сохраняются: по ним /// подтягивается факт и подсвечивается текущая строка. public func rows(at date: Date) -> [WeekDaySlot] { let days = days - return rowSlots(at: date).compactMap { index in - days.indices.contains(index) ? days[index] : nil + let base = scaleBase(at: date) + return rowOrder(at: date).compactMap { index in + guard days.indices.contains(index) else { return nil } + let day = days[index] + // Сутки за скобкой — те, что прошли до начала недели, — остаются + // в недельной шкале: они и правда съели свою долю лимита. + guard let base, index >= base else { return day } + return WeekDaySlot( + index: day.index, + start: day.start, + end: day.end, + planPercent: planPercent(forDay: index, from: base), + isPartial: day.isPartial + ) } } + /// Номер суток, чья строка в ряду отвечает за «сегодня». + /// + /// Обычно это сами текущие сутки, но в день сброса их половина может не + /// попасть в ряд: при `.monday` день сброса представлен утренней + /// половиной, а человек смотрит на панель вечером того же дня. Строка у + /// этого дня недели всё равно одна — её и подсвечиваем, иначе в день + /// сброса панель осталась бы вовсе без «сегодня». + public func highlightedRow(at date: Date) -> Int? { + guard let today = dayIndex(for: date) else { return nil } + let order = rowOrder(at: date) + if order.contains(today) { return today } + let weekday = calendar.component(.weekday, from: dayBounds[today]) + return order.first { calendar.component(.weekday, from: dayBounds[$0]) == weekday } + } + public func timeLeft(from date: Date) -> TimeInterval { max(end.timeIntervalSince(date), 0) } diff --git a/Sources/ClaudeWeekTests/ConfigTests.swift b/Sources/ClaudeWeekTests/ConfigTests.swift index 8a17a15..7952058 100644 --- a/Sources/ClaudeWeekTests/ConfigTests.swift +++ b/Sources/ClaudeWeekTests/ConfigTests.swift @@ -11,6 +11,7 @@ private func tempFile(_ contents: String) -> URL { func runConfigTests(_ t: Harness) { t.suite("конфиг: дефолты") { let d = Config.default + t.equal(d.weekStart, .monday, "неделя по умолчанию начинается понедельником") t.equal(d.resetWeekday, 6, "сброс в пятницу") t.equal(d.resetHour, 16, "сброс в 16:00 — это 15:00 по Москве") t.equal(d.workHours, WorkHours(start: 11, end: 24), "рабочий день 11–24") @@ -27,6 +28,7 @@ func runConfigTests(_ t: Harness) { let c = ConfigStore.load(from: url) t.equal(c.workHours, WorkHours(start: 10, end: 18), "своё значение прочиталось") t.equal(c.resetHour, 16, "остальное осталось дефолтным") + t.equal(c.weekStart, .monday, "конфиг прошлой версии не знает о начале недели — берётся дефолт") } t.suite("конфиг: комментарии в JSON") { diff --git a/Sources/ClaudeWeekTests/PlanTests.swift b/Sources/ClaudeWeekTests/PlanTests.swift index 0930209..1e567b3 100644 --- a/Sources/ClaudeWeekTests/PlanTests.swift +++ b/Sources/ClaudeWeekTests/PlanTests.swift @@ -157,6 +157,46 @@ func runPlanTests(_ t: Harness) { t.equal(freshMetrics.state, .normal, "после сброса — спокойно") } + // Ряд от понедельника живёт в своей шкале: 100 % в нём — это не весь + // недельный лимит, а то, что осталось к утру понедельника. + t.suite("строки дней: шкала от понедельника") { + let window = WeekWindow(containing: at(2026, 8, 9, 12, 0), config: config(weekStart: .monday)) + let now = at(2026, 8, 11, 12, 0) + // Факт по суткам окна: к концу воскресенья съедено 45 % недели. + let snapshot = UsageSnapshot.make( + usedPercent: 52, + cumulativeByDay: [10, 25, 45, 52, nil, nil, nil, nil], + window: window, + source: .local, + fetchedAt: now, + isEstimate: true + ) + let rows = snapshot.rows(at: now) + t.equal(rows.map(\.index), [3, 4, 5, 6, 7, 1, 2], "ряд от понедельника, выходные за скобкой") + + // Остаток к утру понедельника — 55 % недели. Понедельник съел из него + // 52 − 45 = 7, то есть 12,7 % остатка. + t.close(rows[0].usedPercent ?? 0, 100 * 7 / 55, "факт понедельника — доля остатка") + t.close(rows[0].planPercent, 100 * 13 / 56, "и план его суток — тоже") + t.equal(rows[1].usedPercent, nil, "будущие сутки по-прежнему без факта") + + // Выходные остались в недельной шкале: они и правда съели свою долю + // лимита, и пересчитывать её не на что. + t.close(rows[5].usedPercent ?? 0, 25, "суббота — накопительный процент недели") + t.close(rows[6].usedPercent ?? 0, 45, "воскресенье — тоже") + + // Лимит, сожжённый до понедельника, делить не на что. + let burnt = UsageSnapshot.make( + usedPercent: 100, + cumulativeByDay: [40, 70, 100, 100, nil, nil, nil, nil], + window: window, + source: .local, + fetchedAt: now, + isEstimate: true + ) + t.close(burnt.rows(at: now)[0].usedPercent ?? 0, 100, "неделя без остатка — строка полная") + } + t.suite("строки дней") { let now = window.start.addingTimeInterval(window.duration / 2) let snapshot = UsageSnapshot.make( diff --git a/Sources/ClaudeWeekTests/Support.swift b/Sources/ClaudeWeekTests/Support.swift index aeda9e2..43efac4 100644 --- a/Sources/ClaudeWeekTests/Support.swift +++ b/Sources/ClaudeWeekTests/Support.swift @@ -16,12 +16,17 @@ func at( ))! } +/// Тестовый конфиг. Неделя здесь по умолчанию начинается днём сброса, а не +/// понедельником, как в дефолте программы: с этого порядка ряд совпадает с +/// нумерацией суток окна, и проверки границ читаются без пересчёта позиций. +/// Порядок от понедельника проверяется отдельно, с явным `weekStart`. func config( weekday: Int = 6, hour: Int = 15, minute: Int = 0, tz: String = "Europe/Saratov", - work: WorkHours = WorkHours.default + work: WorkHours = WorkHours.default, + weekStart: WeekStart = .reset ) -> Config { var c = Config.default c.resetWeekday = weekday @@ -29,6 +34,7 @@ func config( c.resetMinute = minute c.timeZone = tz c.workHours = work + c.weekStart = weekStart return c } diff --git a/Sources/ClaudeWeekTests/WeekWindowTests.swift b/Sources/ClaudeWeekTests/WeekWindowTests.swift index 3a71af8..31fb238 100644 --- a/Sources/ClaudeWeekTests/WeekWindowTests.swift +++ b/Sources/ClaudeWeekTests/WeekWindowTests.swift @@ -79,6 +79,54 @@ func runWeekWindowTests(_ t: Harness) { "номера суток сохраняются") } + // Неделя, начинающаяся понедельником: ряд повёрнут к нему, а сутки окна, + // прошедшие до понедельника, стоят под текущей неделей — они уже позади. + t.suite("окно недели: начало в понедельник") { + let window = WeekWindow(containing: at(2026, 8, 9, 12, 0), config: config(weekStart: .monday)) + let labels: (Date) -> [String] = { moment in + window.rows(at: moment).map { Formatting.weekdayShort($0.start, calendar: window.calendar) } + } + let monday = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"] + + t.equal(labels(at(2026, 8, 9, 12, 0)), monday, "ряд открывает понедельник, выходные — в конце") + t.equal(labels(at(2026, 8, 14, 9, 0)), monday, + "и в последние сутки окна порядок тот же: он не зависит от момента") + t.equal(window.rows(at: at(2026, 8, 9, 12, 0)).map(\.index), [3, 4, 5, 6, 7, 1, 2], + "номера суток окна сохраняются, меняется только порядок строк") + t.equal(window.rows(at: at(2026, 8, 9, 12, 0)).count, 7, "строк всё те же семь") + + // У ряда своя шкала: понедельник открывается нулём, пятница приходит + // к 100 % в момент сброса. 56 рабочих часов от полуночи понедельника + // до него: четверо полных суток по 13 и четыре часа утра пятницы. + let plan = window.rows(at: at(2026, 8, 9, 12, 0)).map { Int($0.planPercent.rounded()) } + t.equal(plan, [23, 46, 70, 93, 100, 24, 38], + "неделя от понедельника до сброса, выходные — в недельной шкале") + t.close(window.planPercent(forDay: 3, from: 3), 100 * 13 / 56, "понедельник — 13 часов из 56") + t.equal(window.scaleBase(at: at(2026, 8, 9, 12, 0)), 3, "шкала открывается понедельником") + + // Вечер пятницы в ряд не попал, но «сегодня» на панели быть обязано: + // у этого дня недели строка одна, её и подсвечиваем. + t.equal(window.highlightedRow(at: at(2026, 8, 7, 20, 0)), 7, + "вечером дня сброса подсвечена его строка") + t.equal(window.highlightedRow(at: at(2026, 8, 9, 12, 0)), 2, "обычные сутки подсвечивают себя") + t.equal(window.highlightedRow(at: at(2026, 8, 1, 12, 0)), nil, "вне окна подсвечивать нечего") + } + + t.suite("окно недели: понедельник и есть день сброса") { + // Сброс в понедельник 15:00 — тогда разрезан надвое сам понедельник. + // Ряд должна открывать его вечерняя половина: иначе панель начиналась + // бы со ста процентов и дальше падала к десяти. + let window = WeekWindow( + containing: at(2026, 8, 12, 12, 0), + config: config(weekday: 2, weekStart: .monday) + ) + t.equal(window.start, at(2026, 8, 10, 15, 0), "старт — понедельник до него") + t.equal(window.rows(at: at(2026, 8, 12, 12, 0)).map(\.index), [0, 1, 2, 3, 4, 5, 6], + "ряд открывает вечер понедельника, а не его утро через неделю") + let plan = window.rows(at: at(2026, 8, 12, 12, 0)).map { Int($0.planPercent.rounded()) } + t.equal(plan, [10, 24, 38, 53, 67, 81, 96], "план растёт сверху вниз") + } + t.suite("окно недели: сброс в полночь") { // Ровно в полночь календарные сутки и сутки окна совпадают — // резать день сброса не на что. @@ -89,6 +137,15 @@ func runWeekWindowTests(_ t: Harness) { t.check(!window.splitsResetDay, "делить нечего") t.equal(window.rows(at: at(2026, 8, 13, 23, 59)).map(\.index), [0, 1, 2, 3, 4, 5, 6], "ряд не сдвигается ни в какой момент недели") + + // Делить нечего и поворачивать нечего не мешает: ряд от понедельника + // здесь просто крутится по кругу, все семь суток целы. + let fromMonday = WeekWindow( + containing: at(2026, 8, 9, 12, 0), + config: config(hour: 0, weekStart: .monday) + ) + t.equal(fromMonday.rows(at: at(2026, 8, 9, 12, 0)).map(\.index), [3, 4, 5, 6, 0, 1, 2], + "семь суток повёрнуты к понедельнику") } t.suite("окно недели: смена таймзоны") { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9a95aa0..06c73b3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -111,13 +111,30 @@ SwiftUI` — значит, расчёт просочился в UI или нао в полночь, иначе восемь: день сброса разрезан границами окна. Всё, что делит расход по суткам, спрашивает длину у `window.slotCount`, а не у `WeekWindow.daysInWeek` — та осталась длиной окна в сутках. -10. **Строк на панели всегда семь** (`WeekWindow.rows(at:)`, - `UsageSnapshot.rows(at:)`): день сброса занимает одну — ту свою половину, - что идёт сейчас. До полуночи последних суток окна это вечер после сброса - (первая строка), дальше — утро перед следующим (последняя, 100 %). Две - строки с одинаковым «ПТ» и несопоставимыми процентами — то, чего здесь - быть не должно. Нумеруются строки сутками окна, а не позицией в ряду: - по `DayUsage.index` подтягивается факт и подсвечивается текущая. +10. **Строк на панели всегда семь** (`WeekWindow.rowOrder(at:)`, + `UsageSnapshot.rows(at:)`): день сброса занимает одну — двух строк с + одинаковым «ПТ» и несопоставимыми процентами здесь быть не должно. + Порядок задаёт `Config.weekStart`, и только порядок: границы окна и все + цифры от него не зависят. + - `.reset` — ряд от суток сброса, как неделя катится. Показывается та + половина дня сброса, что идёт сейчас: до полуночи последних суток окна + это вечер после сброса (первая строка), дальше — утро перед следующим + (последняя, 100 %). + - `.monday` (по умолчанию) — ряд повёрнут к понедельнику, и половина дня + сброса выбирается не моментом, а местом в ряду: утро перед следующим + сбросом, чтобы план дорастал до 100 % в его строке. Сутки, прошедшие до + понедельника, встают в конец ряда — панель отделяет их чертой. + У такого ряда своя шкала (`WeekWindow.scaleBase(at:)`): план считается + заново по рабочим часам отрезка «полночь понедельника → сброс» + (`planPercent(forDay:from:)`), а факт — долей остатка, что был к этой + полуночи (`UsageSnapshot.rebased`). Знаменатель у факта фактический, а + не плановый: план раскладывается по тому, что осталось на самом деле, + и мерить их разными линейками нельзя. Строки за скобкой остаются + в недельной шкале, как и итог недели в футере. + Нумеруются строки сутками окна, а не позицией в ряду: по `DayUsage.index` + подтягивается факт. Текущую строку ищет `WeekWindow.highlightedRow(at:)` — + в день сброса его половина может не попасть в ряд, и «сегодня» + подсвечивается по дню недели. 11. **План суток считается временем, а не их номером** (`planPercent(forDay:)` зовёт `planPercent(at:)`). Иначе обрезанные сутки получили бы долю полных, а ряд не сошёлся бы к 100 % в момент сброса. diff --git a/docs/USAGE.md b/docs/USAGE.md index 4a204aa..f9a4641 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -269,6 +269,29 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 - **в 16:00 (15:00 МСК) лимит сбрасывается**, окно становится новым, и пятница снова первая строка — уже с новыми 8,8 % на вечер. +Так ряд выглядит при «Начале недели» = **День сброса**. По умолчанию же он +идёт **с понедельника** — так, как неделя устроена у человека, а не у сервера, +— и живёт в своей шкале: **понедельник открывается нулём, пятница приходит к +100 %** в момент сброса. + +| ПН | ВТ | СР | ЧТ | ПТ | ─ | СБ | ВС | +|---|---|---|---|---|---|---|---| +| 22,8 % | 45,6 % | 68,4 % | 91,2 % | 100 % | | 23,1 % | 37,4 % | + +Пятнице здесь достаётся её утро: сброс стоит в конце этих суток. 100 % в этой +шкале — не весь недельный лимит, а **то, что осталось к утру понедельника**: +57 рабочих часов от его полуночи до сброса раскладывают остаток, а факт +считается от той же точки. Сожгли половину лимита за выходные — строки покажут, +как расходуется вторая половина, а не всю неделю разом. + +Суббота с воскресеньем остаются **за скобкой, под чертой в конце ряда**, и +проценты у них недельные: они и правда съели свою долю лимита, и пересчитывать +её не на что. Итог недели в футере — тоже недельный: «осталось 42 %» отвечает +за весь лимит, а строки ПН → ПТ — за его остаток. + +Порядок от момента не зависит: строки стоят на своих местах всю неделю, +меняется только подсвеченная. + Что из этого следует на практике: - **98 % в пятницу к 14:00 — это ровно по плану**, а не перерасход: до сброса @@ -340,8 +363,8 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 | Запускать при входе в систему | автозапуск: заводит или сносит launchd-агента | | Источник данных | `auto` — официальный с падением на локальный, `official` — только сервер, `local` — только оценка по транскриптам | | Обновлять раз в | интервал опроса, 1–30 минут (в сеть — не чаще раза в 60 с) | -| День / час / минута сброса | запасное недельное окно; при живом официальном источнике не используется — момент сброса приходит с сервера | -| Таймзона | в какой зоне считать границы суток | +| Начало недели | с какого дня идёт ряд суток на панели: «Понедельник» (по умолчанию) или «День сброса». Момент сброса это не двигает — его называет сервер; под настройкой показан тот, что придёт следующим | +| Таймзона | в какой зоне считать границы суток и в какой показывать момент сброса | | Рабочий день | распорядок из списка («Рабочий день 10–18», «Длинный день 10–22», «Вечерний 11–24», «С утра до ночи 9–22», «Круглосуточно») или свои часы степперами; по ним раскладывается недельный лимит, ночью план стоит. Строка «Получается» показывает выбранное словами: «11:00 – полуночи, 13 ч в сутки» | «Как в системе» означает русский на русской macOS и английский на любой @@ -565,8 +588,9 @@ launchd-агент не трогаются. | Ключ | По умолчанию | Смысл | |---|---|---| -| `resetWeekday` / `resetHour` / `resetMinute` | `6` / `16` / `0` | запасной момент сброса — пятница 16:00 в `timeZone` ниже, то есть 15:00 МСК; при живом официальном источнике не используется | -| `timeZone` | `Europe/Saratov` | таймзона окна и границ суток; пустая строка — системная. Сменили её — поправьте и `resetHour` | +| `weekStart` | `monday` | с какого дня ряд суток на панели: `monday` или `reset` (день сброса). На цифры не влияет — только порядок строк | +| `resetWeekday` / `resetHour` / `resetMinute` | `6` / `16` / `0` | запасной момент сброса — пятница 16:00 в `timeZone` ниже, то есть 15:00 МСК. В настройках этих полей нет: они идут в дело, только если сервер не отвечал ещё ни разу и кеш пуст | +| `timeZone` | `""` | таймзона окна, границ суток и подписи сброса; пустая строка — системная | | `refreshInterval` | `300` | секунды между обновлениями, минимум 30 | | `provider` | `auto` | `official`, `local` или `auto` | | `workHours.start` / `.end` | `11` / `24` | рабочий день, по которому раскладывается лимит; `24` — полночь. `0`/`24` — считать круглосуточно, как раньше | @@ -688,7 +712,11 @@ ClaudeWeek --verbose подробный лог в stderr Момент сброса берётся из официального ответа (`resets_at`), а не из конфига: сервер знает его точно, а `resetWeekday`/`resetHour` — всего лишь догадка, -которая нужна офлайн. План строки — накопительный, на конец её суток: факт +которая нужна офлайн. Поэтому их и нет в настройках: поле, которое почти +никогда ни на что не влияет, читается как сломанное — его крутят, ждут, что +неделя сдвинется, и не понимают, почему ничего не меняется. Настройками +двигается только порядок строк («Начало недели»), а сам момент сброса — +серверный, и показан рядом с ней прямым текстом. План строки — накопительный, на конец её суток: факт в строке тоже накопительный, и сравнивать его с планом на середину значило бы всю неделю показывать перерасход там, где его нет. diff --git a/docs/images/panel-contrast-dark.png b/docs/images/panel-contrast-dark.png index f646d49..f7b1a79 100644 Binary files a/docs/images/panel-contrast-dark.png and b/docs/images/panel-contrast-dark.png differ diff --git a/docs/images/panel-contrast-light.png b/docs/images/panel-contrast-light.png index 313809c..d37f5f9 100644 Binary files a/docs/images/panel-contrast-light.png and b/docs/images/panel-contrast-light.png differ diff --git a/docs/images/panel-dark.png b/docs/images/panel-dark.png index 123ddf5..63e201e 100644 Binary files a/docs/images/panel-dark.png and b/docs/images/panel-dark.png differ diff --git a/docs/images/panel-graphite-dark.png b/docs/images/panel-graphite-dark.png index 8c84b81..0b365bd 100644 Binary files a/docs/images/panel-graphite-dark.png and b/docs/images/panel-graphite-dark.png differ diff --git a/docs/images/panel-graphite-light.png b/docs/images/panel-graphite-light.png index b8aa82d..a8280cd 100644 Binary files a/docs/images/panel-graphite-light.png and b/docs/images/panel-graphite-light.png differ diff --git a/docs/images/panel-light.png b/docs/images/panel-light.png index 7e3084c..213afa5 100644 Binary files a/docs/images/panel-light.png and b/docs/images/panel-light.png differ diff --git a/docs/images/panel-midnight-dark.png b/docs/images/panel-midnight-dark.png index 7bc9026..a3c191b 100644 Binary files a/docs/images/panel-midnight-dark.png and b/docs/images/panel-midnight-dark.png differ diff --git a/docs/images/panel-midnight-light.png b/docs/images/panel-midnight-light.png index 0c4d1bc..6ecdd6b 100644 Binary files a/docs/images/panel-midnight-light.png and b/docs/images/panel-midnight-light.png differ diff --git a/docs/images/panel-paper-dark.png b/docs/images/panel-paper-dark.png index 0d4be50..c389323 100644 Binary files a/docs/images/panel-paper-dark.png and b/docs/images/panel-paper-dark.png differ diff --git a/docs/images/panel-paper-light.png b/docs/images/panel-paper-light.png index 0d4be50..c389323 100644 Binary files a/docs/images/panel-paper-light.png and b/docs/images/panel-paper-light.png differ diff --git a/docs/images/panel-system-dark.png b/docs/images/panel-system-dark.png index 123ddf5..63e201e 100644 Binary files a/docs/images/panel-system-dark.png and b/docs/images/panel-system-dark.png differ diff --git a/docs/images/panel-system-light.png b/docs/images/panel-system-light.png index 7e3084c..213afa5 100644 Binary files a/docs/images/panel-system-light.png and b/docs/images/panel-system-light.png differ