diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae3fd1..e8c3738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,64 @@ ## [Не выпущено] +### Добавлено + +- **Подпись сброса недельного лимита в заголовке панели показывает число + месяца.** Было «сброс ПТ 16:00» — теперь «сброс 14 ПТ 16:00». День недели + один на всё окно и сам по себе не путал, с каким днём сравнивать, но число + рядом читается без счёта в уме. +- **Настройка «Дневной план»** (вкладка «Общие» → «Расширенные настройки»): + выключает семь суточных полос с планом и парой чисел «факт / план», оставляя + одну полосу на всю неделю — тот же вид, что у пятичасовой сессии, голый + процент без сравнения с графиком. Выключена по умолчанию: панель из коробки + — пятичасовая сессия и итог недели, без разбора по дням; включается тем, + кому разбор нужен. Заливка этой полосы (и заодно полосы пятичасовой сессии — + раньше у неё заливка на красный порог не реагировала вовсе) краснеет на + критическом пороге: рядом с ней нет плановой зоны, с которой красный + сливался бы на дейтеранопии, — та причина, по которой суточные полосы + заливку красным не красят, здесь не применима. + +### Изменено + +- **Окно настроек тянется мышью по высоте, а ширина зафиксирована.** Форма + `.grouped` в macOS сама не даёт своим строкам стать шире примерно + семисот точек, и лишняя ширина уходила не в настройки, а в пустые поля по + бокам. Длинные вкладки прокручиваются — растянуть окно вниз можно, чтобы + увидеть их целиком. Шрифт подсказок под настройками увеличен, а сами они + теперь идут во всю ширину окна, а не только в узкую колонку значений формы. +- **Вкладка «Общие» перестроена.** Пояснения под «Языком» и «Источником + данных» убраны; вариант источника «Официальный, с падением на локальный» + назван короче — «Всегда онлайн». Внизу вкладки — раскрывающийся пункт + **«Расширенные настройки»** (свёрнут по умолчанию, целиком нажимается вся + строка заголовка, а не системный треугольник размером с букву): в нём + «Дневной план» и «Показывать» (переехали со вкладки «Панель», так как без + дневного плана они ни на что не влияют), «Начало недели», «Таймзона» и + «Рабочий день». Заводской рабочий день сменился с «Вечерний 11–24» на + «Рабочий день 10–18». +- **Вкладка «Строка меню»**: пороги цвета («Недельный лимит» и «Пятичасовая + сессия») убраны под такую же раскрывающуюся секцию «Расширенные настройки». +- **Вкладка «Панель»** лишилась секции «Суточные полосы» целиком — «Дневной + план» и «Показывать» теперь на вкладке «Общие». +- **Вкладка «Уведомления»**: три абзаца пояснений убраны, пороги обоих + лимитов — под раскрывающейся секцией «Расширенные настройки». +- **Вкладка «Доступ» сокращена в разы**: секции «Можно и без токена» и «Если + macOS снова спросила доступ» удалены целиком, абзацы про метку аккаунта и + про токен сжаты до одной фразы каждый. Из интерфейса ушло только + повторение — тот же текст остаётся в [docs/USAGE.md](docs/USAGE.md). + +### Исправлено + +- Строка пятичасовой сессии в панели не краснела на критическом пороге (по + умолчанию 95 %): значок ⚠ и цифра процента загорались красным только при + полном исчерпании лимита (100 %), хотя строка меню и заголовок недельного + лимита красятся раньше, уже на критическом пороге. Теперь оба элемента + строки сессии следуют тому же порогу. Заливка самой полосы по-прежнему не + становится красной — это сделано намеренно, ради различимости при + дальтонизме, — тревога, как и раньше, живёт в значке и цифре. +- Сводка футера («осталось 42 % · сброс через 3 дн 18 ч») была на кегль мельче + строки «сброс …» под пятичасовой сессией — разница читалась как случайная. + Теперь обе строки одного кегля. + ## [0.1.14] — 2026-08-19 ### Добавлено diff --git a/README.md b/README.md index 83f1c61..5914f03 100644 --- a/README.md +++ b/README.md @@ -132,10 +132,12 @@ Code сбрасывает каждым обновлением токена, и ## Что показывает - **Неделя с планом** — семь суточных полос: факт, плановая зона, перерасход. - 100 % лимита раскладываются по **рабочим часам** (по умолчанию 11:00 → 00:00), - а не по астрономическим: сон не должен весить столько же, сколько работа. - Ряд идёт с понедельника, а сутки, прошедшие сразу после сброса, стоят под - чертой в конце; в настройках его можно развернуть от дня сброса. + Включается в настройках галкой «Дневной план» (по умолчанию панель короче — + без разбора по дням, только сессия и итог недели). 100 % лимита + раскладываются по **рабочим часам** (по умолчанию 10:00 → 18:00), а не по + астрономическим: сон не должен весить столько же, сколько работа. Ряд идёт + с понедельника, а сутки, прошедшие сразу после сброса, стоят под чертой в + конце; в настройках его можно развернуть от дня сброса. - **Пятичасовую сессию** — отдельной строкой над сутками, с часом сброса. Тот лимит, в который упираются чаще недельного. - **Прогноз** — «при таком темпе кончится ПТ 15:57», тоже по рабочим часам. diff --git a/Sources/ClaudeWeekApp/PanelModel.swift b/Sources/ClaudeWeekApp/PanelModel.swift index 3184d59..3d32b88 100644 --- a/Sources/ClaudeWeekApp/PanelModel.swift +++ b/Sources/ClaudeWeekApp/PanelModel.swift @@ -133,8 +133,12 @@ final class PanelModel { /// Сколько строк рисовать до прихода данных: столько же, сколько потом, /// иначе панель дёрнется в высоте, когда данные наконец придут. + /// + /// Дневной план выключен — на месте дней всегда одна полоса недели, и + /// плейсхолдер-заглушка тоже одна: считать дни окна незачем. var placeholderRows: Int { - showsWholeWeek ? WeekWindow(containing: now, config: config).rowCount : 1 + guard config.appearance.showsPlan else { return 1 } + return showsWholeWeek ? WeekWindow(containing: now, config: config).rowCount : 1 } /// Пятичасовая сессия — только пока её окно не истекло. После сброса от diff --git a/Sources/ClaudeWeekApp/PopoverView.swift b/Sources/ClaudeWeekApp/PopoverView.swift index ca7cff4..0c2b895 100644 --- a/Sources/ClaudeWeekApp/PopoverView.swift +++ b/Sources/ClaudeWeekApp/PopoverView.swift @@ -55,11 +55,15 @@ struct PopoverView: View { if let snapshot = model.snapshot { // Разбивка встаёт ровно на место дней, в ту же сетку: щелчок // по цифрам меняет содержание строк, а не открывает второе - // место, где те же проценты сказаны по-своему. + // место, где те же проценты сказаны по-своему. Дневной план + // выключен в настройках — дней вовсе нет, и на их месте одна + // полоса на всю неделю, тем же видом, что у сессии. if model.showsModels { models(snapshot) - } else { + } else if appearance.showsPlan { days(snapshot) + } else { + weekRow(snapshot) } } else { placeholders @@ -224,6 +228,19 @@ struct PopoverView: View { } } + // MARK: Неделя без плана + + /// Замена ряда дней, когда дневной план выключен: голый процент недели + /// одной полосой. + private func weekRow(_ snapshot: UsageSnapshot) -> some View { + WeekRow( + usedPercent: snapshot.usedPercent, + state: model.state, + animated: !reduceMotion, + onValueTap: toggleModels + ) + } + // MARK: Модели /// Разбивка на месте дней. Возвращает обратно тот же клик по цифрам — @@ -306,7 +323,7 @@ struct PopoverView: View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { Text(summary) - .font(Theme.footerFont) + .font(Theme.captionFont) .foregroundStyle( model.resetIsClose ? palette.warning.color : palette.secondaryText.color ) @@ -314,7 +331,7 @@ struct PopoverView: View { if appearance.showForecast, let forecast { Text(forecast) - .font(Theme.footerFont) + .font(Theme.captionFont) .foregroundStyle(palette.critical.color) } } diff --git a/Sources/ClaudeWeekApp/Screenshot.swift b/Sources/ClaudeWeekApp/Screenshot.swift index 8d1a7f9..24e7708 100644 --- a/Sources/ClaudeWeekApp/Screenshot.swift +++ b/Sources/ClaudeWeekApp/Screenshot.swift @@ -66,10 +66,12 @@ enum Screenshot { // Порядок строк — тоже вид, а не окно: картинки должны показывать // заводскую неделю от понедельника, чей бы конфиг ни лежал рядом. config.weekStart = Config.default.weekStart - // Единственное отступление от заводского вида: недельный ряд. Из - // коробки панель компактная, но картинка в README должна показывать - // то, ради чего программа написана, — все семь суток разом. Как - // свернуть панель до сегодняшнего дня, сказано в руководстве. + // Два отступления от заводского вида, оба ради одной и той же + // картинки: панель из коробки короче — без дневного плана и с + // компактным рядом, — а в README нужно показать то, ради чего + // программа написана, — все семь суток разом с плановой зоной. Как + // свернуть их обратно, сказано в руководстве. + config.appearance.showsPlan = true config.appearance.panelLayout = .week // Материал строки меню в оффскрин-рендер не попадает — фон рисуем // сплошным цветом палитры. @@ -255,12 +257,14 @@ enum Screenshot { ) // Высота больше оконной: в живом окне вкладка прокручивается, а на - // картинке нужны все три секции разом — иначе нижнюю половину - // настроек пришлось бы пересказывать словами. + // картинке нужны все секции разом — иначе нижнюю половину настроек + // пришлось бы пересказывать словами. Пороги — с ходу раскрытыми: + // свёрнутая по умолчанию секция на этом снимке прятала бы то, ради + // чего он вообще существует. return hosted( - NotificationSettings(model: model) + NotificationSettings(model: model, showsAdvanced: true) .environment(\.colorScheme, name == .darkAqua ? .dark : .light), - size: NSSize(width: 640, height: 830), + size: NSSize(width: SettingsView.width, height: 780), appearance: appearance ) } diff --git a/Sources/ClaudeWeekApp/SessionRow.swift b/Sources/ClaudeWeekApp/SessionRow.swift index e53a1bc..44eebe8 100644 --- a/Sources/ClaudeWeekApp/SessionRow.swift +++ b/Sources/ClaudeWeekApp/SessionRow.swift @@ -1,16 +1,22 @@ import SwiftUI import ClaudeWeekCore -/// Полоса пятичасовой сессии: одна заливка на общем треке, без плановой зоны. -/// Плана здесь нет намеренно — пятичасовое окно не обязано расходоваться -/// равномерно (можно просто закончить работу), и синяя «сколько положено -/// к этому часу» изображала бы темп, которого у сессии по смыслу нет. -struct SessionBar: View { +/// Полоса лимита без плановой зоны: одна заливка на общем треке. Общая для +/// пятичасовой сессии и — когда дневной план недели выключен в настройках — +/// для недели целиком. У обоих плана нет по смыслу или его прячут нарочно: +/// сессия не обязана расходоваться равномерно (можно просто закончить +/// работу), а голый недельный процент как раз про то, чтобы не сравнивать +/// факт с графиком. +/// +/// Заливка красная на критическом пороге — так же, как полоса в строке +/// меню (`MenuBarBar.fillColor`). У суточных полос это не так: там красный +/// в заливку нарочно не идёт, потому что рядом с ней всегда стоит плановая +/// зона того же зелёного ряда, и на дейтеранопии переход в красный от неё +/// неотличим. Здесь плановой зоны нет, сравнивать заливку не с чем, и красный +/// однозначно читается как «граница пройдена». +struct LimitBar: View { let usedPercent: Double - /// Лимит на исходе — заливка желтеет. Красный в заливку не идёт по той же - /// причине, что и в суточных полосах: с зелёным он неразличим при - /// дейтеранопии, поэтому тревога живёт в тексте и значке. - let isWarning: Bool + let state: LimitState let animated: Bool @Environment(\.palette) private var palette @@ -22,13 +28,21 @@ struct SessionBar: View { ZStack(alignment: .leading) { Capsule().fill(palette.track.color) Capsule() - .fill(isWarning ? palette.warning.color : palette.good.color) + .fill(fillColor) .frame(width: filled) } .animation(animated ? .easeOut(duration: Theme.fillAnimation) : nil, value: usedPercent) } .frame(height: Theme.barHeight) } + + private var fillColor: Color { + switch state { + case .normal: palette.good.color + case .warning: palette.warning.color + case .critical, .exhausted: palette.critical.color + } + } } /// Строка сессии в сетке суточных полос, но над разделителем: это второй, @@ -66,8 +80,10 @@ struct SessionRow: View { @Environment(\.palette) private var palette @Environment(\.strings) private var s - private var isWarning: Bool { - state != .normal + /// Лимит на исходе или уже исчерпан — тут краснеют значок, цифры и сама + /// заливка полосы, все по одному порогу (см. `LimitBar`). + private var isCritical: Bool { + state == .critical || state == .exhausted } var body: some View { @@ -83,9 +99,9 @@ struct SessionRow: View { // панель от клика по кружку не меняют размера. Отсюда и одна // строка текста — второй ряд раздвинул бы строку по высоте, а // за ней и всю панель. Длинный отказ дочитывается подсказкой. - SessionBar( + LimitBar( usedPercent: session.usedPercent, - isWarning: isWarning, + state: state, animated: animated ) .opacity(showsSourceText ? 0 : 1) @@ -109,7 +125,7 @@ struct SessionRow: View { // десять минут до конца окна и 90 % за четыре часа значат разное. Text(caption) .font(Theme.captionFont) - .foregroundStyle(session.isExhausted ? palette.critical.color : palette.secondaryText.color) + .foregroundStyle(isCritical ? palette.critical.color : palette.secondaryText.color) .padding(.leading, Theme.dayLabelWidth + 8) } .accessibilityElement(children: .ignore) @@ -123,7 +139,7 @@ struct SessionRow: View { @ViewBuilder private var percent: some View { let column = HStack(spacing: Theme.valueGap) { - if session.isExhausted { + if isCritical { Text("⚠") .font(Theme.dayFont) .foregroundStyle(palette.critical.color) @@ -131,7 +147,7 @@ struct SessionRow: View { Text(Formatting.percent(session.usedPercent)) .font(Theme.dayFont) .fixedSize() - .foregroundStyle(session.isExhausted ? palette.critical.color : palette.primaryText.color) + .foregroundStyle(isCritical ? palette.critical.color : palette.primaryText.color) } .frame(width: Theme.valueWidth, alignment: .trailing) diff --git a/Sources/ClaudeWeekApp/SettingsView.swift b/Sources/ClaudeWeekApp/SettingsView.swift index 6c2d1ac..1677f68 100644 --- a/Sources/ClaudeWeekApp/SettingsView.swift +++ b/Sources/ClaudeWeekApp/SettingsView.swift @@ -9,6 +9,10 @@ import ClaudeWeekCore struct SettingsView: View { @Bindable var model: SettingsModel + /// Ширина окна: ровно столько, сколько форма отдаёт своим строкам. Шире + /// растут только поля по бокам, поэтому вбок окно и не тянется. + static let width: CGFloat = 720 + private var s: L10n { model.config.strings } var body: some View { @@ -31,7 +35,98 @@ struct SettingsView: View { AboutSettings(model: model) .tabItem { Label(s.pick("О программе", "About"), systemImage: "info.circle") } } - .frame(width: 640, height: 580) + // Ширина закреплена, высота тянется. Форма `.grouped` в macOS сама + // не даёт своим строкам стать шире примерно семисот точек, и лишняя + // ширина окна уходила не в настройки, а в пустые поля по бокам — + // тянуть вбок было незачем и некрасиво. Высота другое дело: длинные + // вкладки прокручиваются, и растянуть окно вниз — единственный способ + // увидеть их целиком. + // + // Высота по умолчанию взята по самой длинной вкладке, а не по средней: + // вкладки переключаются в одном окне, и размер под короткую заставлял + // бы прокручивать все остальные. + .frame( + minWidth: Self.width, idealWidth: Self.width, maxWidth: Self.width, + minHeight: 460, idealHeight: 780, maxHeight: .infinity + ) + } +} + +/// Пояснение под настройкой — тем же кеглем и цветом во всех шести вкладках. +/// +/// `maxWidth: .infinity` здесь не украшение: `Form` в macOS кладёт одинокий +/// `Text` в узкую колонку значений, и растянутое мышью окно оставляло бы +/// пояснение висеть в прежней ширине, обтекая пустоту справа. С ним текст +/// перебивается по живой ширине окна, а `fixedSize` по вертикали не даёт +/// строке обрезаться вместо переноса. +private extension View { + func settingsHint() -> some View { + font(.body) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// Имя раскрывающейся секции — одно на все вкладки: человек, раскрывший её +/// однажды, должен узнавать её и на соседней вкладке. +private extension L10n { + var advancedTitle: String { pick("Расширенные настройки", "Advanced settings") } +} + +/// Раскрывающаяся секция «Расширенные настройки» — одна на все вкладки, и +/// раскрывается везде одинаково. +/// +/// Заголовок здесь свой, а не встроенный в `Section(isExpanded:)`: у того +/// нажимается только треугольник размером с букву, и попасть в него стоит +/// отдельного прицеливания. Кнопка во всю ширину строки принимает щелчок +/// куда угодно в заголовке — и в текст, и в пустоту справа от него. +private struct AdvancedSection: View { + let title: String + @Binding var isExpanded: Bool + @ViewBuilder var content: Content + + var body: some View { + Section { + if isExpanded { content } + } header: { + Button { + withAnimation(.easeOut(duration: 0.15)) { isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + Text(title) + // Растягивает кнопку до края формы: без него щелчок мимо + // букв уходил бы в пустоту, а целиться в слово из двух + // слогов ровно та же морока, что и в треугольник. + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + // Курсор-палец: заголовок формы сам по себе не выглядит нажимаемым, + // и без подсказки о том, что он кнопка, догадываются не сразу. + .onHover { inside in + if inside { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + } + } +} + +/// Подзаголовок внутри «Расширенных настроек». Секция там одна на вкладку, а +/// разговоров в ней бывает два — про недельный лимит и про пятичасовую +/// сессию, — и без подписей четыре одинаковых ползунка слились бы в один ряд, +/// где не видно, который к чему. +private struct GroupTitle: View { + let text: String + + var body: some View { + Text(text) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -47,8 +142,8 @@ struct PercentRow: View { HStack { Slider(value: $value, in: 0...100, step: 1) Text(Formatting.percent(value)) - .font(.caption.monospacedDigit()) - .frame(width: 50, alignment: .trailing) + .font(.body.monospacedDigit()) + .frame(width: 58, alignment: .trailing) } } } @@ -59,7 +154,12 @@ struct PercentRow: View { private struct GeneralSettings: View { @Bindable var model: SettingsModel + /// Свёрнута при каждом открытии окна, а не запоминается: раскрытая + /// секция — то, что человек делает сейчас, а не настройка. + @State private var showsAdvanced = false + private var config: Binding { $model.config } + private var appearance: Binding { $model.config.appearance } /// Строки на выбранном языке. Читаются из конфига, а не из глобальной /// переменной: смена языка меняет конфиг, а по нему SwiftUI перерисует @@ -74,29 +174,23 @@ private struct GeneralSettings: View { Text(language.title(s.lang)).tag(language) } } - Text(s.languageHint) - .font(.caption) - .foregroundStyle(.secondary) } Section(s.pick("Запуск", "Startup")) { Toggle(s.pick("Запускать при входе в систему", "Launch at login"), isOn: launchAtLogin) .disabled(!LoginItem.isAvailable) - Text(launchHint) - .font(.caption) - .foregroundStyle(.secondary) + // Пояснения под галочкой больше нет, а погашенной она + // бывает — у отладочного `swift run` автозапускать нечего. + // Подсказкой при наведении, чтобы не выглядело поломкой. + .help(launchHint) } Section(s.pick("Источник данных", "Data source")) { Picker(s.pick("Откуда брать цифры", "Where the numbers come from"), selection: config.provider) { - Text(s.pick("Официальный, с падением на локальный", "Official, local as fallback")) - .tag(ProviderPreference.auto) + Text(s.pick("Всегда онлайн", "Always online")).tag(ProviderPreference.auto) Text(s.pick("Только официальный", "Official only")).tag(ProviderPreference.official) Text(s.pick("Только локальная оценка", "Local estimate only")).tag(ProviderPreference.local) } - Text(providerHint) - .font(.caption) - .foregroundStyle(.secondary) LabeledContent(s.pick("Обновлять раз в", "Refresh every")) { HStack { @@ -106,30 +200,53 @@ private struct GeneralSettings: View { step: 30 ) Text(Formatting.duration(model.config.refreshInterval, lang: s.lang)) - .font(.caption.monospacedDigit()) - .frame(width: 70, alignment: .trailing) + .font(.body.monospacedDigit()) + .frame(width: 78, alignment: .trailing) } } } - Section(s.pick("Недельное окно", "Week window")) { - Picker(s.pick("Начало недели", "Week starts on"), selection: config.weekStart) { - ForEach(WeekStart.allCases, id: \.self) { start in - Text(start.title(s.lang)).tag(start) + // Раскрывающаяся секция, а не `DisclosureGroup` внутри обычной: + // группа вкладывает свои строки в одну ячейку формы, и они теряют + // и колонки, и разделители — треугольник открывался в мелкую кашу + // вместо ряда настроек. Здесь же раскрываются полноценные строки + // формы. + AdvancedSection(title: s.advancedTitle, isExpanded: $showsAdvanced) { + // Первым — переключатель суточных полос: он решает судьбу + // почти всего, что под ним. Пояснять это текстом больше не + // нужно — выключенный план тут же убирает «Показывать» и + // «Начало недели», и связь видно глазами. + Toggle(s.pick("Дневной план", "Daily plan"), isOn: appearance.showsPlan) + + if model.config.appearance.showsPlan { + Picker(s.pick("Показывать", "Show"), selection: appearance.panelLayout) { + ForEach(PanelLayout.allCases, id: \.self) { layout in + Text(layout.title(s.lang)).tag(layout) + } + } + .pickerStyle(.segmented) + Text(layoutHint) + .settingsHint() + + Picker(s.pick("Начало недели", "Week starts on"), selection: config.weekStart) { + ForEach(WeekStart.allCases, id: \.self) { start in + Text(start.title(s.lang)).tag(start) + } } + Text(model.weekStartNote) + .settingsHint() + } else { + Text(noPlanHint) + .settingsHint() } + Picker(s.pick("Таймзона", "Time zone"), selection: config.timeZone) { Text(s.pick("Системная", "System")).tag("") ForEach(popularZones, id: \.self) { zone in Text(zone).tag(zone) } } - Text(model.weekStartNote) - .font(.caption) - .foregroundStyle(.secondary) - } - Section(s.pick("Рабочий день", "Working day")) { Picker(s.pick("Распорядок", "Schedule"), selection: config.workHours) { ForEach(WorkHours.presets, id: \.self) { hours in Text(hours.title(s.lang)).tag(hours) @@ -144,25 +261,26 @@ private struct GeneralSettings: View { // Границы держат день непустым: вывернутый интервал конфиг // чинит на круглосуточный, и в степпере это выглядело бы как // самовольный сброс настройки. - HStack { - Stepper( - s.pick("С \(model.config.workHours.start):00", "From \(model.config.workHours.start):00"), - value: config.workHours.start, - in: 0...(model.config.workHours.end - 1) - ) - Stepper( - s.pick("до \(hourLabel(model.config.workHours.end))", - "to \(hourLabel(model.config.workHours.end))"), - value: config.workHours.end, - in: (model.config.workHours.start + 1)...24 - ) + LabeledContent(s.pick("Часы", "Hours")) { + HStack(spacing: 16) { + Stepper( + s.pick("с \(model.config.workHours.start):00", + "from \(model.config.workHours.start):00"), + value: config.workHours.start, + in: 0...(model.config.workHours.end - 1) + ) + Stepper( + s.pick("до \(hourLabel(model.config.workHours.end))", + "to \(hourLabel(model.config.workHours.end))"), + value: config.workHours.end, + in: (model.config.workHours.start + 1)...24 + ) + } } LabeledContent(s.pick("Получается", "Adds up to"), value: workHoursSummary) Text(workHoursHint) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } - } .formStyle(.grouped) } @@ -239,17 +357,42 @@ private struct GeneralSettings: View { """) } - private var providerHint: String { - switch model.config.provider { - case .auto: - s.pick("Как в /usage; когда сеть или авторизация отваливаются — локальная оценка с пометкой ≈.", - "Same as /usage; when the network or the token gives out, a local estimate marked ≈.") - case .official: - s.pick("Только цифры сервера. Нет сети — панель честно скажет, что данных нет.", - "Server numbers only. No network — the panel plainly says it has no data.") - case .local: - s.pick("Считает по транскриптам ~/.claude/projects. Работает офлайн, точность зависит от калибровки.", - "Counted from transcripts in ~/.claude/projects. Works offline; accuracy depends on calibration.") + /// Что осталось от суточных полос, когда их выключили. Про рабочие часы + /// здесь сказано отдельно: они переживают выключенный план — по ним + /// по-прежнему растёт плановая зона в значке строки меню и считается + /// прогноз «кончится в …». + private var noPlanHint: String { + s.pick(""" + Дней в панели нет — вместо семи полос с планом одна на всю неделю, \ + голый факт без сравнения с графиком. Начало недели вместе с ними \ + уходит: переставлять нечего. Рабочие часы остаются в деле — по ним \ + растёт плановая зона в значке строки меню и считается прогноз \ + «кончится в …». + """, """ + There are no day rows in the panel — instead of seven bars with a \ + plan there is one for the whole week, the bare spend with no pacing \ + comparison. The week start goes with them: there is nothing left to \ + reorder. Working hours still count — they drive the plan zone in the \ + menu bar icon and the “runs out at …” forecast. + """) + } + + private var layoutHint: String { + switch model.config.appearance.panelLayout { + case .week: + s.pick("Семь полос, по дню недели каждая: весь ряд перед глазами.", + "Seven bars, one per weekday: the whole row in front of you.") + case .compact: + s.pick(""" + Только текущие сутки — панель короче на шесть строк. Неделя не \ + потеряна: щёлкните по строке дня, и ряд раскроется целиком, пока \ + панель открыта. Итог недели и час сброса остаются на месте в любом \ + случае. + """, """ + Today only — six rows shorter. The week is not lost: click the day \ + row and the whole row unfolds for as long as the panel stays open. \ + The week total and the reset time stay put either way. + """) } } @@ -268,6 +411,9 @@ private struct GeneralSettings: View { private struct MenuBarSettings: View { @Bindable var model: SettingsModel + /// Свёрнута при каждом открытии окна — как и на соседних вкладках. + @State private var showsAdvanced = false + private var thresholds: Thresholds { model.config.thresholds } private var s: L10n { model.config.strings } @@ -281,8 +427,7 @@ private struct MenuBarSettings: View { Text(s.pick("Кольцо с процентом", "Ring with percentage")).tag(MenuBarStyle.ring) } Text(styleHint) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() // Расклад кольца спрашиваем только когда оно выбрано: у полосы // второго лимита нет, и пункт стоял бы там без смысла. @@ -293,8 +438,7 @@ private struct MenuBarSettings: View { } } Text(ringHint) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } } @@ -309,16 +453,17 @@ private struct MenuBarSettings: View { from the fill and the number. The panel is coloured either way: \ the thresholds below also colour its heading and session bar. """)) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } - Section(s.pick("Недельный лимит", "Weekly limit")) { + // Пороги — под раскрывающейся секцией: заводские отметки трогают + // редко, а места они занимают на пол-вкладки. + AdvancedSection(title: s.advancedTitle, isExpanded: $showsAdvanced) { + GroupTitle(text: s.pick("Недельный лимит", "Weekly limit")) PercentRow(title: s.pick("Жёлтый после", "Amber after"), value: weekWarn) PercentRow(title: s.pick("Красный после", "Red after"), value: weekCritical) - } - Section(s.pick("Пятичасовая сессия", "5-hour session")) { + GroupTitle(text: s.pick("Пятичасовая сессия", "5-hour session")) PercentRow(title: s.pick("Жёлтый после", "Amber after"), value: sessionWarn) PercentRow(title: s.pick("Красный после", "Red after"), value: sessionCritical) Text(s.pick(""" @@ -334,8 +479,7 @@ private struct MenuBarSettings: View { The session comes from the official source only: on a local \ estimate its percentage stays at zero. """)) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } } .formStyle(.grouped) @@ -451,8 +595,7 @@ private struct AppearanceSettings: View { .pickerStyle(.segmented) Text(themeHint) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } Section(s.pick("Фон панели", "Panel background")) { @@ -463,16 +606,16 @@ private struct AppearanceSettings: View { Slider(value: appearance.panelTintOpacity, in: 0...1, step: 0.01) .disabled(!model.config.appearance.transparentPanel) Text(String(format: "%.0f %%", model.config.appearance.panelTintOpacity * 100)) - .font(.caption.monospacedDigit()) - .frame(width: 50, alignment: .trailing) + .font(.body.monospacedDigit()) + .frame(width: 58, alignment: .trailing) } } LabeledContent(s.pick("Скругление углов", "Corner radius")) { HStack { Slider(value: appearance.cornerRadius, in: 0...24, step: 1) Text("\(Int(model.config.appearance.cornerRadius)) pt") - .font(.caption.monospacedDigit()) - .frame(width: 50, alignment: .trailing) + .font(.body.monospacedDigit()) + .frame(width: 58, alignment: .trailing) } } Text(s.pick(""" @@ -488,38 +631,7 @@ private struct AppearanceSettings: View { that is a separate mode. The panel itself shows the result: it \ hangs by the menu bar while this window is open. """)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Section(s.pick("Суточные полосы", "Day bars")) { - Picker(s.pick("Показывать", "Show"), selection: appearance.panelLayout) { - ForEach(PanelLayout.allCases, id: \.self) { layout in - Text(layout.title(s.lang)).tag(layout) - } - } - .pickerStyle(.segmented) - Text(layoutHint) - .font(.caption) - .foregroundStyle(.secondary) - } - - Section(s.pick("Строки панели", "Panel rows")) { - Toggle(s.pick("Пятичасовая сессия", "5-hour session"), isOn: appearance.showSession) - Toggle(s.pick("Прогноз «кончится в …»", "Forecast “runs out at …”"), isOn: appearance.showForecast) - Text(s.pick(""" - Откуда взяты цифры, говорит кружок рядом с полосой сессии: \ - залитый зелёный — ответ сервера, залитый жёлтый — он же, но \ - из кеша, контурный красный — локальная оценка. Наведите на \ - него, и панель скажет, что именно случилось. - """, """ - Where the numbers come from is told by the dot next to the \ - session bar: solid green — a live server reply, solid amber — \ - the same reply from cache, hollow red — a local estimate. Hover \ - it and the panel says what exactly happened. - """)) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } Section(s.pick("Сброс сессии", "Session reset")) { @@ -533,7 +645,7 @@ private struct AppearanceSettings: View { LabeledContent(s.pick("Выглядит так", "Looks like this")) { Text(sessionResetSample) - .font(.caption.monospacedDigit()) + .font(.body.monospacedDigit()) .foregroundStyle(.secondary) } @@ -548,8 +660,7 @@ private struct AppearanceSettings: View { it to show. An hour past midnight is labelled with the weekday: \ a 5-hour window steps over midnight easily. """)) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } } .formStyle(.grouped) @@ -570,25 +681,6 @@ private struct AppearanceSettings: View { return s.pick("сброс \(reset)", "resets \(reset)") } - private var layoutHint: String { - switch model.config.appearance.panelLayout { - case .week: - s.pick("Семь полос, по дню недели каждая: весь ряд перед глазами.", - "Seven bars, one per weekday: the whole row in front of you.") - case .compact: - s.pick(""" - Только текущие сутки — панель короче на шесть строк. Неделя не \ - потеряна: щёлкните по строке дня, и ряд раскроется целиком, пока \ - панель открыта. Итог недели и час сброса остаются на месте в любом \ - случае. - """, """ - Today only — six rows shorter. The week is not lost: click the day \ - row and the whole row unfolds for as long as the panel stays open. \ - The week total and the reset time stay put either way. - """) - } - } - private var themeHint: String { switch model.config.appearance.theme { case .system: @@ -612,6 +704,17 @@ private struct AppearanceSettings: View { struct NotificationSettings: View { @Bindable var model: SettingsModel + /// Свёрнута при каждом открытии окна — как и на соседних вкладках. + /// Параметр, а не всегда `false`, — ради `Screenshot.notificationsTab`: + /// картинка в README существует затем, чтобы показать именно пороги, + /// и снимать её со свёрнутой секцией было бы бессмысленно. + @State private var showsAdvanced: Bool + + init(model: SettingsModel, showsAdvanced: Bool = false) { + self.model = model + self._showsAdvanced = State(initialValue: showsAdvanced) + } + private var notifications: NotificationsConfig { model.config.notifications } /// Пороги настраиваются и при выключенных уведомлениях — гасим их только /// вместе с самим тумблером, чтобы не пришлось включать баннеры ради того, @@ -626,8 +729,7 @@ struct NotificationSettings: View { Toggle(s.pick("Предупреждать о приближении к лимиту", "Warn when a limit gets close"), isOn: enabled) Text(model.notifications.summary(s)) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() if model.notifications.needsSystemSettings { Button(s.pick("Открыть настройки уведомлений macOS", "Open macOS notification settings")) { model.notifications.openSystemSettings() @@ -639,90 +741,54 @@ struct NotificationSettings: View { Toggle(s.pick("Сообщать о новой версии", "Announce a new version"), isOn: updates) - - Text(s.pick(""" - В баннере две строки: сколько израсходовано и через сколько \ - сброс. Какой это лимит, говорит картинка справа — пятичасовая \ - сессия приходит дугой, недельный лимит красным числом. - """, """ - The banner is two lines: how much is spent and how long until \ - the reset. Which limit it is comes from the artwork on the \ - right — the 5-hour session arrives as an arc, the weekly limit \ - as a red number. - """)) - .font(.caption) - .foregroundStyle(.secondary) - - Text(s.pick(""" - О вышедшей версии программа говорит один раз — не каждые сутки, \ - пока не обновитесь. Кнопки в том баннере нет: установка \ - перезаписывает приложение и перезапускает его, и делать это \ - одним щелчком посреди чужой работы неправильно. Ставится \ - обновление на вкладке «О программе». - """, """ - A released version is announced once — not daily until you \ - update. That banner has no button: installing replaces the app \ - and restarts it, and that should not happen on a single click \ - in the middle of your work. Updates are installed on the About \ - tab. - """)) - .font(.caption) - .foregroundStyle(.secondary) - - Text(s.pick(""" - Об одном пороге программа говорит один раз за окно лимита и \ - только на ухудшении: откатившийся расход молчит, а следующая \ - неделя и следующая сессия начинают отсчёт заново. Два баннера \ - подряд не приходят ближе, чем через пять минут. - """, """ - A threshold is announced once per limit window and only on the \ - way up: a spend that fell back stays quiet, while the next week \ - and the next session start counting afresh. Two banners never \ - come closer than five minutes apart. - """)) - .font(.caption) - .foregroundStyle(.secondary) } - Section(s.pick("Недельный лимит", "Weekly limit")) { - Toggle(s.pick("Уведомлять о недельном лимите", "Notify about the weekly limit"), - isOn: weekEnabled) - PercentRow(title: s.pick("Предупредить после", "Warn after"), value: weekFirst) - PercentRow(title: s.pick("И ещё раз после", "And again after"), value: weekSecond) - previewButton(.week) - Text(s.pick(""" - Неделя не сбросится до её конца, поэтому предупреждать о ней \ - стоит раньше: после первого порога расход ещё можно растянуть \ - на оставшиеся дни. - """, """ - The week will not reset before it ends, so it deserves an \ - earlier warning: after the first threshold the spend can still \ - be stretched over the days that are left. - """)) - .font(.caption) - .foregroundStyle(.secondary) - } - .disabled(isOff) - - Section(s.pick("Пятичасовая сессия", "5-hour session")) { - Toggle(s.pick("Уведомлять о пятичасовой сессии", "Notify about the 5-hour session"), - isOn: sessionEnabled) - PercentRow(title: s.pick("Предупредить после", "Warn after"), value: sessionFirst) - PercentRow(title: s.pick("И ещё раз после", "And again after"), value: sessionSecond) - previewButton(.session) - Text(s.pick(""" - Сессию сообщает только официальный источник: на локальной \ - оценке её процент не считается вовсе, и уведомлений о ней \ - не будет. - """, """ - The session comes from the official source only: a local \ - estimate does not compute its percentage at all, so there will \ - be no notifications about it. - """)) - .font(.caption) - .foregroundStyle(.secondary) + // Пороги обоих лимитов — под раскрывающейся секцией, как пороги + // цвета на вкладке «Строка меню». Гасим не саму секцию, а её + // содержимое: у выключенной заголовок перестал бы раскрываться, + // и посмотреть, на чём стоят отметки, стало бы нельзя, не включив + // уведомления. + AdvancedSection(title: s.advancedTitle, isExpanded: $showsAdvanced) { + Group { + GroupTitle(text: s.pick("Недельный лимит", "Weekly limit")) + Toggle(s.pick("Уведомлять о недельном лимите", "Notify about the weekly limit"), + isOn: weekEnabled) + PercentRow(title: s.pick("Предупредить после", "Warn after"), value: weekFirst) + PercentRow(title: s.pick("И ещё раз после", "And again after"), value: weekSecond) + previewButton(.week) + Text(s.pick(""" + Неделя не сбросится до её конца, поэтому предупреждать о ней \ + стоит раньше: после первого порога расход ещё можно растянуть \ + на оставшиеся дни. + """, """ + The week will not reset before it ends, so it deserves an \ + earlier warning: after the first threshold the spend can still \ + be stretched over the days that are left. + """)) + .settingsHint() + } + .disabled(isOff) + + Group { + GroupTitle(text: s.pick("Пятичасовая сессия", "5-hour session")) + Toggle(s.pick("Уведомлять о пятичасовой сессии", "Notify about the 5-hour session"), + isOn: sessionEnabled) + PercentRow(title: s.pick("Предупредить после", "Warn after"), value: sessionFirst) + PercentRow(title: s.pick("И ещё раз после", "And again after"), value: sessionSecond) + previewButton(.session) + Text(s.pick(""" + Сессию сообщает только официальный источник: на локальной \ + оценке её процент не считается вовсе, и уведомлений о ней \ + не будет. + """, """ + The session comes from the official source only: a local \ + estimate does not compute its percentage at all, so there will \ + be no notifications about it. + """)) + .settingsHint() + } + .disabled(isOff) } - .disabled(isOff) } .formStyle(.grouped) } @@ -835,22 +901,10 @@ private struct AccessSettings: View { Section(s.pick("Аккаунт и счёт", "Account and count")) { LabeledContent(s.pick("Сейчас в ключе", "Currently in the key")) { Text(model.account ?? s.pick("не читается", "cannot be read")) - .font(.caption.monospaced()) + .font(.body.monospaced()) .foregroundStyle(model.account == nil ? .secondary : .primary) .textSelection(.enabled) } - Text(s.pick(""" - Метка аккаунта из записи Keychain: начало UUID организации и тариф. \ - Токен обновляется раз в час, а она держится — по ней и видно, тот \ - же это аккаунт, что вчера, или вошли другим. - """, """ - The account mark from the Keychain item: the start of the \ - organisation UUID and the plan. The token is refreshed hourly, this \ - stays — it is what tells yesterday’s account from a new login. - """)) - .font(.caption) - .foregroundStyle(.secondary) - HStack { Button(s.pick("Начать счёт заново", "Start counting over")) { confirmingReset = true @@ -858,105 +912,21 @@ private struct AccessSettings: View { Spacer() } Text(model.countingNote) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() - Text(s.pick(""" - Нужно после входа другим аккаунтом. Недельный процент приходит от \ - сервера и сменится сам, а вот разбивка по суткам считается по \ - транскриптам в ~/.claude/projects — они пишутся в одни и те же \ - файлы при любом аккаунте, и различить их по содержимому нельзя. \ - Смену аккаунта программа замечает и сама, но только пока читает \ - Keychain. - """, """ - Needed after logging in with a different account. The weekly \ - percentage comes from the server and changes by itself, but the \ - daily breakdown is counted from the transcripts in \ - ~/.claude/projects — they are written to the same files whatever \ - account is used, and nothing in them tells one from the other. The \ - app notices a switch on its own too, but only while it can read the \ - Keychain. - """)) - .font(.caption) - .foregroundStyle(.secondary) + Text(s.pick( + "Нужно после входа другим аккаунтом: суточная разбивка идёт по транскриптам, а в них аккаунт не различить.", + "Needed after logging in with a different account: the daily breakdown comes from transcripts, which do not tell accounts apart." + )) + .settingsHint() } Section(s.pick("Токен для официального источника", "Token for the official source")) { - Text(s.pick(""" - Берётся из Keychain Claude Code, запись «Claude Code-credentials». \ - Виджет видит ровно тот аккаунт, что показывает /usage: сервер узнаёт \ - его по этому токену. Запись только читается — обновляет её сам \ - Claude Code, и лезть туда вдвоём значит потерять токен. - """, """ - Taken from the Claude Code Keychain item “Claude Code-credentials”. \ - The widget sees exactly the account /usage shows: the server \ - recognises it by this token. The item is only ever read — Claude \ - Code refreshes it itself, and two writers mean a lost token. - """)) - .font(.caption) - .foregroundStyle(.secondary) - - Text(s.pick(""" - Вставить свой токен нельзя, и это не упущение: /api/oauth/usage \ - принимает только токен сеанса Claude Code. Ключ API (sk-ant-api…) и \ - годовой токен от claude setup-token он отвергает с 401 — проверено. - """, """ - Pasting your own token is not possible, and that is not an \ - oversight: /api/oauth/usage accepts a Claude Code session token \ - only. An API key (sk-ant-api…) and the year-long token from \ - claude setup-token are both rejected with 401 — tested. - """)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Section(s.pick("Можно и без токена", "It works without a token too")) { - Text(s.pick(""" - Доступ к записи Keychain можно не давать вовсе: на вкладке «Общие» \ - выберите «Только локальная оценка» — расход посчитается по вашим же \ - транскриптам в ~/.claude/projects, без сети и без единого секрета. \ - Цена отказа — знак ≈ перед процентом. - """, """ - You can withhold Keychain access entirely: pick “Local estimate \ - only” on the General tab and the spend is counted from your own \ - transcripts in ~/.claude/projects — no network, not a single \ - secret. The price of saying no is a ≈ in front of the percentage. - """)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Section(s.pick("Если macOS снова спросила доступ", "If macOS asks for access again")) { - Text(s.pick(""" - Запись читается утилитой /usr/bin/security — той же, которой \ - пишет токен сам Claude Code, и потому разрешение на неё \ - восстанавливается при каждом обновлении токена. Прямой запрос \ - к Keychain остался запасным путём и диалога не показывает: \ - не пустили — панель уходит на локальную оценку. - """, """ - The item is read through /usr/bin/security — the same tool Claude \ - Code writes the token with, which is why the permission is \ - restored every time the token is refreshed. A direct Keychain \ - query remains as a fallback and shows no dialog: denied, the \ - panel falls back to a local estimate. - """)) - .font(.caption) - .foregroundStyle(.secondary) - - Text(s.pick(""" - Вопрос всё же вернулся — значит доступа нет и у security. \ - Заведите постоянный сертификат подписи: ./scripts/signing-cert.sh \ - в каталоге исходников, затем ./scripts/install.sh. Он держит \ - приложение в списке доверенных приложений записи, и обновления \ - переподписываются им же. - """, """ - The prompt came back anyway — then security has no access either. \ - Set up a stable signing certificate: ./scripts/signing-cert.sh in \ - the source directory, then ./scripts/install.sh. It keeps the app \ - on the item’s trusted list, and updates are re-signed with it. - """)) - .font(.caption) - .foregroundStyle(.secondary) + Text(s.pick( + "Берётся из Keychain Claude Code и только читается. Свой вставить нельзя: сервер принимает лишь токен сеанса Claude Code.", + "Taken from the Claude Code Keychain item, read-only. Pasting your own is not possible: the server accepts a Claude Code session token only." + )) + .settingsHint() } Section(s.pick("Проверка", "Check")) { @@ -969,7 +939,7 @@ private struct AccessSettings: View { Spacer() if let result = model.checkResult { Text(result.text) - .font(.caption) + .font(.body) .foregroundStyle(result.ok ? .green : .red) .multilineTextAlignment(.trailing) } @@ -981,8 +951,7 @@ private struct AccessSettings: View { The token goes nowhere but api.anthropic.com, is never written to \ config.json and never reaches the log. """)) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } } .formStyle(.grouped) @@ -1060,8 +1029,7 @@ private struct AboutSettings: View { } } Text(updateHint) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } Section(s.pick("Файлы", "Files")) { @@ -1083,8 +1051,7 @@ private struct AboutSettings: View { worked out and the calibration — those the app earned from live \ data. """)) - .font(.caption) - .foregroundStyle(.secondary) + .settingsHint() } } .formStyle(.grouped) @@ -1136,7 +1103,7 @@ private struct AboutSettings: View { LabeledContent(title) { HStack(spacing: 8) { Text(url.path) - .font(.caption.monospaced()) + .font(.body.monospaced()) .lineLimit(1) .truncationMode(.head) .textSelection(.enabled) diff --git a/Sources/ClaudeWeekApp/SettingsWindow.swift b/Sources/ClaudeWeekApp/SettingsWindow.swift index 8045cbf..a8f19a8 100644 --- a/Sources/ClaudeWeekApp/SettingsWindow.swift +++ b/Sources/ClaudeWeekApp/SettingsWindow.swift @@ -236,11 +236,21 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { let hosting = NSHostingController(rootView: SettingsView(model: model)) let window = NSWindow(contentViewController: hosting) window.title = model.config.strings.pick("Настройки ClaudeWeek", "ClaudeWeek Settings") - window.styleMask = [.titled, .closable, .miniaturizable] + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] window.isReleasedWhenClosed = false window.delegate = self window.center() - window.setFrameAutosaveName("ClaudeWeekSettings") + // Имя со счётчиком: размер по умолчанию сменился, а сохранённый кадр + // прошлой версии оказался бы сильнее нового — окно открывалось бы + // прежним, и правка размера прошла бы мимо всех, кто уже запускал + // программу. Меняем имя вместе с размером — старый кадр забыт один раз. + window.setFrameAutosaveName(Self.frameName) + // Имя автосохранения само по себе только пишет размер в defaults — + // читает его `setFrameUsingName`. Пока окно было нетянущимся, разницы + // не было; теперь растянутое окно должно открываться растянутым и в + // следующий запуск, а не схлопываться обратно к идеальному размеру. + window.setFrameUsingName(Self.frameName) + fitToScreen(window) // moveToActiveSpace — чтобы окно приходило на тот стол, с которого его // позвали, а не утаскивало экран туда, где его открыли в первый раз. // fullScreenAuxiliary — чтобы настройки пускали и поверх полноэкранного @@ -267,6 +277,30 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { window.makeKeyAndOrderFront(nil) } + /// Ключ, под которым размер окна лежит в defaults. Со счётчиком версии: + /// смена правил размера иначе не дошла бы до тех, у кого кадр уже + /// сохранён, — старый кадр сильнее новых значений по умолчанию. + private static let frameName = "ClaudeWeekSettings.v3" + + /// Окно открывается высотой с самую длинную вкладку, а экран бывает + /// короче этого — на ноутбуке или на половине вертикального монитора. + /// Подрезаем по видимой области и возвращаем в центр: окно, у которого + /// нижний край за краем экрана, не изменить размер мышью. + /// + /// Ширину трогаем на всякий случай тем же движением: она закреплена + /// в `SettingsView`, но экран уже её ширины — не выдумка, а альбомный + /// монитор, поставленный на бок. + private func fitToScreen(_ window: NSWindow) { + guard let visible = (window.screen ?? NSScreen.main)?.visibleFrame else { return } + var frame = window.frame + guard frame.width > visible.width || frame.height > visible.height else { return } + frame.size.width = min(frame.width, visible.width) + frame.size.height = min(frame.height, visible.height) + frame.origin.x = visible.midX - frame.width / 2 + frame.origin.y = visible.midY - frame.height / 2 + window.setFrame(frame, display: false) + } + /// Двигаем окно один раз, при создании: дальше место выбирает человек. /// Уходим в ту сторону, где на экране больше свободного места, а если /// вбок не влезаем — опускаемся под панель. diff --git a/Sources/ClaudeWeekApp/Theme.swift b/Sources/ClaudeWeekApp/Theme.swift index 7c4d922..9c553d4 100644 --- a/Sources/ClaudeWeekApp/Theme.swift +++ b/Sources/ClaudeWeekApp/Theme.swift @@ -269,8 +269,9 @@ enum Theme { static let todayFont = Font.system(size: menuFontSize, weight: .semibold).monospacedDigit() /// Кнопки футера: глифы ⚙ и ⟳ в кегле сноски становятся неприцельными. static let actionFont = Font.system(size: menuFontSize) - /// Сводка футера — самая мелкая строка панели: в кегле сноски «осталось - /// 42 % · сброс через 3 дн 18 ч» вместе с прогнозом рвётся на два ряда. + /// Баннер новой версии над футером — самая мелкая строка панели: в кегле + /// сноски «↑ Доступна версия…» вместе с «в настройках →» не помещаются + /// рядом при длинном номере версии. static let footerFont = Font.system(size: menuFontSize - 3).monospacedDigit() static let captionFont = Font.system(size: captionFontSize).monospacedDigit() diff --git a/Sources/ClaudeWeekApp/WeekRow.swift b/Sources/ClaudeWeekApp/WeekRow.swift new file mode 100644 index 0000000..03ff84f --- /dev/null +++ b/Sources/ClaudeWeekApp/WeekRow.swift @@ -0,0 +1,70 @@ +import SwiftUI +import ClaudeWeekCore + +/// Итог недели одной полосой без плановой зоны — тем же видом, что у строки +/// пятичасовой сессии. Встаёт на место семи суточных полос, когда дневной +/// план в настройках выключен: раскладывать факт по дням незачем, если не с +/// чем его сравнивать, и панель показывает голый процент недели. +struct WeekRow: View { + let usedPercent: Double + let state: LimitState + let animated: Bool + /// Нажатие на процент — открыть разбивку по моделям, тот же жест, что у + /// суточных строк и строки сессии. + var onValueTap: (() -> Void)? + + @Environment(\.palette) private var palette + @Environment(\.strings) private var s + + private var isCritical: Bool { state == .critical || state == .exhausted } + + var body: some View { + HStack(spacing: 8) { + Text(s.pick("НЕД", "WK")) + .font(Theme.dayFont) + .foregroundStyle(palette.secondaryText.color) + .frame(width: Theme.dayLabelWidth, alignment: .leading) + + LimitBar(usedPercent: usedPercent, state: state, animated: animated) + + percent + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(voiceOverLabel) + .accessibilityAction(named: s.pick("Расход по моделям", "Spend by model")) { onValueTap?() } + } + + /// Процент недели — он же кнопка разбивки по моделям, тот же приём, что + /// в строках дней и сессии. + @ViewBuilder + private var percent: some View { + let column = HStack(spacing: Theme.valueGap) { + if isCritical { + Text("⚠") + .font(Theme.dayFont) + .foregroundStyle(palette.critical.color) + } + Text(Formatting.percent(usedPercent)) + .font(Theme.dayFont) + .fixedSize() + .foregroundStyle(isCritical ? palette.critical.color : palette.primaryText.color) + } + .frame(width: Theme.valueWidth, alignment: .trailing) + + if let onValueTap { + column + .contentShape(Rectangle()) + .onTapGesture(perform: onValueTap) + .help(DayRow.valueHint(s)) + } else { + column + } + } + + private var voiceOverLabel: String { + let percent = Formatting.percent(usedPercent, withSign: false) + let verdict = isCritical ? s.pick(", лимит на исходе", ", limit almost spent") : "" + return s.pick("Лимит недели, потрачено \(percent) процентов\(verdict)", + "Weekly limit, \(percent) per cent spent\(verdict)") + } +} diff --git a/Sources/ClaudeWeekCore/Config.swift b/Sources/ClaudeWeekCore/Config.swift index 90a19aa..bbfc7a1 100644 --- a/Sources/ClaudeWeekCore/Config.swift +++ b/Sources/ClaudeWeekCore/Config.swift @@ -130,6 +130,16 @@ public struct AppearanceConfig: Codable, Sendable, Equatable { public var sessionReset: SessionResetDisplay /// Весь недельный ряд или только текущие сутки. public var panelLayout: PanelLayout + /// Дневной план недели: семь полос с синей плановой зоной и парой чисел + /// «факт / план». Выключенный убирает их совсем, а на их месте встаёт + /// одна полоса на всю неделю — того же вида, что у пятичасовой сессии: + /// голый факт без сравнения с графиком. `panelLayout` в этом виде + /// значения не имеет — делить нечего, строка одна. + /// + /// По умолчанию выключен: панель из двух полос — сессия и неделя — + /// отвечает на вопрос «сколько осталось» без разбора по дням, а разбор + /// включается тем, кому он нужен. + public var showsPlan: Bool // Значения по умолчанию — тот вид, к которому пришла обкатка: плотная // вуаль поверх материала, контрастная тема, компактная панель. Прогноз @@ -143,7 +153,8 @@ public struct AppearanceConfig: Codable, Sendable, Equatable { showSession: Bool = true, showForecast: Bool = false, sessionReset: SessionResetDisplay = .both, - panelLayout: PanelLayout = .compact + panelLayout: PanelLayout = .compact, + showsPlan: Bool = false ) { self.theme = theme self.transparentPanel = transparentPanel @@ -153,6 +164,7 @@ public struct AppearanceConfig: Codable, Sendable, Equatable { self.showForecast = showForecast self.sessionReset = sessionReset self.panelLayout = panelLayout + self.showsPlan = showsPlan } // Как и в `Config`: каждое поле необязательно. Конфиг, записанный прошлой @@ -172,7 +184,8 @@ public struct AppearanceConfig: Codable, Sendable, Equatable { showForecast: try c.decodeIfPresent(Bool.self, forKey: .showForecast) ?? d.showForecast, sessionReset: try c.decodeIfPresent(SessionResetDisplay.self, forKey: .sessionReset) ?? d.sessionReset, - panelLayout: try c.decodeIfPresent(PanelLayout.self, forKey: .panelLayout) ?? d.panelLayout + panelLayout: try c.decodeIfPresent(PanelLayout.self, forKey: .panelLayout) ?? d.panelLayout, + showsPlan: try c.decodeIfPresent(Bool.self, forKey: .showsPlan) ?? d.showsPlan ) } @@ -348,7 +361,11 @@ public struct Config: Codable, Sendable, Equatable { timeZone: "", refreshInterval: 300, provider: .auto, - workHours: WorkHours.default, + // Не `WorkHours.default` — та (11–24) остаётся запасным значением для + // сломанных чисел в `WorkHours.validated()` и опорной точкой во всех + // тестах плана, которые не задают часы явно. Свежий же конфиг должен + // начинаться с обычного дня: 10–18. + workHours: WorkHours(start: 10, end: 18), menuBarStyle: .ring, ringArc: .session, weeklyBudget: 0, diff --git a/Sources/ClaudeWeekCore/Formatting.swift b/Sources/ClaudeWeekCore/Formatting.swift index 664ae7f..72f1662 100644 --- a/Sources/ClaudeWeekCore/Formatting.swift +++ b/Sources/ClaudeWeekCore/Formatting.swift @@ -119,14 +119,27 @@ public enum Formatting { : "$\(text)" } - /// «сброс ПТ 16:00» — час сброса в зоне окна, той же, по которой панель - /// считает сутки. Московского хвоста для сверки здесь больше нет: два часа - /// подряд читались как спорящие, а нужен всегда только свой. + /// «сброс 14 ПТ 16:00» — число, день недели и час сброса в зоне окна, той + /// же, по которой панель считает сутки. Московского хвоста для сверки + /// здесь больше нет: два часа подряд читались как спорящие, а нужен + /// всегда только свой. + /// + /// Число месяца добавлено к дню недели не для устранения неоднозначности + /// — сброс всегда в пределах недели вперёд, и «ПТ» одно на всё окно, — + /// а потому что голый день недели требует знать, какое сегодня число, + /// чтобы посчитать разницу; число рядом с ним читается сразу. public static func resetLabel(_ window: WeekWindow, lang: Lang = .ru) -> String { let end = window.end + let date = dayOfMonth(end, calendar: window.calendar) let day = weekdayShort(end, calendar: window.calendar, lang: lang) let time = clock(end, calendar: window.calendar) - return L10n(lang).pick("сброс \(day) \(time)", "resets \(day) \(time)") + return L10n(lang).pick("сброс \(date) \(day) \(time)", "resets \(date) \(day) \(time)") + } + + /// Число месяца без ведущего нуля — «7», «29». Один и тот же формат для + /// обоих языков: число само по себе локали не требует. + public static func dayOfMonth(_ date: Date, calendar: Calendar) -> String { + "\(calendar.component(.day, from: date))" } /// Хвост подписи сессии: «через 1 ч 12 мин», «в 14:35» или «через 1 ч 12 diff --git a/Sources/ClaudeWeekCore/Localization.swift b/Sources/ClaudeWeekCore/Localization.swift index 51839d8..21d807d 100644 --- a/Sources/ClaudeWeekCore/Localization.swift +++ b/Sources/ClaudeWeekCore/Localization.swift @@ -99,16 +99,6 @@ public struct L10n: Sendable, Equatable { extension L10n { public var languageTitle: String { pick("Язык", "Language") } - - public var languageHint: String { - pick(""" - «Как в системе» — русский на русской macOS и английский на любой другой. \ - Выбор применяется сразу, перезапускать не нужно. - """, """ - “System” means Russian on a Russian macOS and English on any other. \ - The choice applies immediately — no restart needed. - """) - } } /// Строка, у которой есть обе версии сразу. diff --git a/Sources/ClaudeWeekTests/ConfigTests.swift b/Sources/ClaudeWeekTests/ConfigTests.swift index 7952058..c2a859e 100644 --- a/Sources/ClaudeWeekTests/ConfigTests.swift +++ b/Sources/ClaudeWeekTests/ConfigTests.swift @@ -14,7 +14,7 @@ func runConfigTests(_ t: Harness) { 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") + t.equal(d.workHours, WorkHours(start: 10, end: 18), "рабочий день 10–18") t.equal(ConfigStore.load(from: URL(fileURLWithPath: "/nope/нет-такого.json")), Config.default.validated(), "отсутствующий файл — дефолты") } @@ -224,6 +224,22 @@ func runConfigTests(_ t: Harness) { "непонятное значение — дефолты, а не падение") } + t.suite("конфиг: дневной план") { + t.check(!Config.default.appearance.showsPlan, "по умолчанию дневной план выключен") + + let url = tempFile(#"{ "appearance": { "showsPlan": true } }"#) + defer { try? FileManager.default.removeItem(at: url) } + t.check(ConfigStore.load(from: url).appearance.showsPlan, "включённый план прочитался") + + // Ключа showsPlan в конфиге прошлой версии нет — берётся дефолт, + // а всё остальное в том конфиге остаётся как записано. + let old = tempFile(#"{ "appearance": { "theme": "graphite" } }"#) + defer { try? FileManager.default.removeItem(at: old) } + let c = ConfigStore.load(from: old) + t.check(!c.appearance.showsPlan, "новый ключ взялся из дефолтов") + t.equal(c.appearance.theme, .graphite, "прежний внешний вид уцелел") + } + t.suite("конфиг: старый файл без внешнего вида") { // Конфиг, написанный прошлой версией: новых ключей в нём нет, // и появиться они должны дефолтными, а не уронить разбор. diff --git a/Sources/ClaudeWeekTests/WeekWindowTests.swift b/Sources/ClaudeWeekTests/WeekWindowTests.swift index 31fb238..2d470d4 100644 --- a/Sources/ClaudeWeekTests/WeekWindowTests.swift +++ b/Sources/ClaudeWeekTests/WeekWindowTests.swift @@ -46,7 +46,7 @@ func runWeekWindowTests(_ t: Harness) { t.equal(labels, ["ПТ", "СБ", "ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ"], "подписи суток окна") t.check(window.days[0].isPartial && window.days[7].isPartial, "крайние сутки обрезаны") t.check(window.days[1...6].allSatisfy { !$0.isPartial }, "середина недели — полные сутки") - t.equal(Formatting.resetLabel(window), "сброс ПТ 15:00", "подпись сброса в своей зоне") + t.equal(Formatting.resetLabel(window), "сброс 14 ПТ 15:00", "подпись сброса в своей зоне") } // Двух пятниц на панели быть не должно: это один день недели, разрезанный diff --git a/docs/API.md b/docs/API.md index ed1bb31..4cfff0c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -165,8 +165,8 @@ Claude Code обновляет токен сам, и следующая попы `resets_at` **округляется до минуты** — у обоих лимитов. Сервер отдаёт его с секундным дрейфом — в одном ответе `12:00:00.357993`, в следующем -`11:59:59.9…`, — и без округления подпись прыгает между «сброс ПТ 16:00» и -«сброс ПТ 15:59», а обратный отсчёт сессии дёргается на минуту туда-сюда. Сброс +`11:59:59.9…`, — и без округления подпись прыгает между «сброс 14 ПТ 16:00» и +«сброс 14 ПТ 15:59», а обратный отсчёт сессии дёргается на минуту туда-сюда. Сброс имеет минутное разрешение; полминуты на недельном окне не значат ничего. **Пятичасовая сессия кешируется вместе со своим `resets_at`.** Локальный diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 53cd1b6..f8fe804 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -154,6 +154,14 @@ SwiftUI` — значит, расчёт просочился в UI или нао 14. **Кривой конфиг чинится, а не отвергается** (`Config.validated()`): потерять строку меню из-за опечатки в JSON нельзя. 15. **Токен не логируется, не пишется в конфиг и не выводится в UI.** +16. **`WorkHours.default` (11–24) — не заводской рабочий день, а запасное + значение.** Его берёт `WorkHours.validated()` при сломанных часах и + `Support.config()` в тестах, не задавших свои явно — вся арифметика плана + в тестах посчитана именно под 11–24. Заводской же день у нового конфига — + 10–18, и он прописан отдельно, прямо в `Config.default` (`workHours: + WorkHours(start: 10, end: 18)`), а не через `WorkHours.default`: поменяй + там — и разъедутся ожидания полусотни тестов плана, которые вообще не про + заводской день. --- @@ -165,9 +173,10 @@ SwiftUI` — значит, расчёт просочился в UI или нао | `StatusItemController.swift` | **склейка всего**: пункт строки меню, таймеры, сон/пробуждение, смена таймзоны, перечитывание конфига, открытие панели и настроек | | `PanelModel.swift` | состояние для SwiftUI: снимок, статус, текущий момент, производные подписи | | `DropdownPanel.swift` | окно панели: форма как у системного меню, материал, обводка, позиция под строкой меню своего монитора, закрытие по клику мимо, закрепление на время настроек | -| `PopoverView.swift` | вёрстка панели: заголовок, сессия, ряд дней (семь строк или одни текущие сутки — `appearance.panelLayout`), футер | +| `PopoverView.swift` | вёрстка панели: заголовок, сессия, ряд дней (семь строк или одни текущие сутки — `appearance.panelLayout`) либо, при выключенном `appearance.showsPlan`, одна полоса недели вместо ряда, футер | | `DayBar.swift` | одна двухцветная полоса и строка дня; в компактном виде строка ещё и раскрывает неделю кликом (`DayRowTap`), а цифры справа открывают разбивку по моделям (`valueTap`) | -| `SessionRow.swift` | полоса пятичасовой сессии; её процент нажимается так же, как цифры дня | +| `SessionRow.swift` | полоса пятичасовой сессии (`LimitBar` — общая с недельной полосой ниже); её процент нажимается так же, как цифры дня | +| `WeekRow.swift` | недельный итог одной `LimitBar` без плановой зоны — на месте `DayBar`-ряда, когда `appearance.showsPlan == false` | | `ModelRow.swift` | строка разбивки: доля модели полосой и числом. Встаёт на место строк дней, когда `PanelModel.showsModels` — отдельного окна у разбивки нет | | `SourceDot.swift` | кружок источника: цвет и заливка говорят, свои цифры или чужие | | `MenuBarBar.swift` | иконка строки меню: полоса и процент в два этажа | @@ -366,6 +375,13 @@ mtime) — атомарная запись меняет inode, поэтому н плюс `expandsWeek`, раскрытие кликом. Раскрытие держит модель, а не вид: сбрасывает его открытие панели (`StatusItemController.prepareToShowPanel`), а закрывают панель щелчком мимо и по Esc, мимо SwiftUI. +- **Ряд дней целиком** — `appearance.showsPlan`, по умолчанию `false`. + Выключенный убирает `DayBar`-ряд и ставит на его место `WeekRow` + (`PopoverView.weekRow(_:)`): та же `LimitBar`, что у `SessionRow`, без + плановой зоны. `panelLayout` в этом виде не смотрят — делить нечего. + Настройка в окне — вкладка «Общие», внизу, под «Расширенными настройками» + (`SettingsView.GeneralSettings`), а не «Панель»: рядом с ней там же живут + «Начало недели» и «Рабочий день» — тоже про разбивку по суткам. - **Плотность фона** живёт в `PopoverView.backdrop`: вуаль поверх материала. Материал системы в оффскрин-рендер не попадает, поэтому `--screenshot` выключает прозрачность и рисует сплошной фон палитры. @@ -554,6 +570,12 @@ Intel — собирает у себя, `install.sh` соберёт нативн перекрашивается в `viewDidChangeEffectiveAppearance` (`BorderedView`). - **Тесты не покрывают UI.** Всё, что делает `ClaudeWeekApp`, проверяется глазами: `--screenshot` и установка. +- **`NSWindow.setFrameAutosaveName` не читает сохранённый кадр сама** — + читает его отдельный `setFrameUsingName`, и сохранённый кадр сильнее любых + новых значений по умолчанию в коде. Поменял идеальный размер окна настроек + (`SettingsView.width`, `.frame(idealHeight:)`) — значит меняй и + `SettingsWindowController.frameName`: без нового имени у всех, кто уже + открывал окно, останется прежний размер, будто правка прошла мимо них. - **`--screenshot` рисует выдуманный момент**, а не текущий: демо-снимок подаётся через `model.apply(_:at:)` вместе со своим «сейчас». Забыть `at:` — значит получить на картинке для README подпись «данные 1 дн 15 ч назад». diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ecf8688..d1cbcd8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -151,6 +151,26 @@ XCTest без Xcode недоступен, поэтому проверки нап - **Глобальная горячая клавиша.** У ClaudeUsageBar — ⌘U из любого места. Требует разрешения «Универсального доступа», поэтому только по явной просьбе и с объяснением, зачем. +- **Новость об обновлении никуда не ведёт.** Кнопка «Обновить» живёт на + вкладке «О программе» (`AboutSettings`), а дойти до неё человек обязан сам: + строка «↑ вышла версия …» в панели зовёт `onSettings` и открывает окно на + первой вкладке, «Общие»; баннер `announceUpdate` пересказывает дорогу + словами, а нажатие на него не обрабатывается вовсе — у `BannerDelegate` есть + только `willPresent`. Надо, чтобы и строка, и баннер приводили ровно к + кнопке обновления. Мешает: `TabView` в `SettingsView` без `selection` — + вкладку не выбрать программно; нужны состояние выбранной вкладки в + `SettingsModel` и `didReceive` в делегате центра уведомлений. +- **Процент в панели молчит, пока полоса уже горит.** На снимке сессии полоса + жёлтая на 96 %, а цифра «96 %» справа — обычная белая: она красится только + при `session.isExhausted`, то есть от 100 % (`SessionRow.swift`, строки 112 + и 134 — `isExhausted ? critical : primaryText`). Порог у полосы другой и уже + посчитан рядом: `isWarning` = `state != .normal`, то есть пороги лимита из + настроек (81/95). Надо, чтобы цифра краснела по тому же порогу, что красит + полосу и дугу кольца, — тем более что красный в саму заливку не идёт + намеренно: с зелёным он неразличим при дейтеранопии, и тревога по замыслу + живёт как раз в тексте и значке (комментарий к `SessionBar`). Сейчас текст + до сотни не тревожится вовсе. Речь про панель; кольцо строки меню, где дуга + и цифра показывают разные лимиты и красятся раздельно, не трогаем. --- diff --git a/docs/USAGE.md b/docs/USAGE.md index 9bd964a..f39ca2d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -189,12 +189,25 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 равномерно, и синяя «сколько положено к этому часу» изображала бы темп, которого у сессии по смыслу нет. +### Без дневного плана + +**По умолчанию панель не разбирает расход по дням вовсе.** Вместо семи +суточных полос — одна, на всю неделю: тот же вид, что у пятичасовой сессии, +голый процент без сравнения с графиком. Панель короче ровно на разбивку по +суткам. Заливка этой полосы, в отличие от суточных, краснеет на критическом +пороге — границе, за которой нет плановой зоны рядом, которая на дейтеранопии +сливалась бы с красным. + +Включается разбивка в настройках: вкладка **Общие** → **Расширенные +настройки** → галка **«Дневной план»**. С ней появляются семь полос с синей +плановой зоной и парой чисел «факт / план». + ### Вся неделя или только сегодня -По умолчанию панель показывает весь ряд. В настройках, вкладка **Панель**, -его можно свернуть до текущих суток: остаются пятичасовая сессия и одна -дневная полоса, а заголовок с часом сброса и футер с итогом недели — на месте. -Панель выходит короче на шесть строк. +Пока дневной план включён, ряд можно свернуть до текущих суток — там же, +пунктом **«Показывать»**: остаются пятичасовая сессия и одна дневная полоса, а +заголовок с часом сброса и футер с итогом недели — на месте. Панель выходит +короче на шесть строк. Неделя при этом не потеряна: **щёлкните по строке дня, и ряд раскроется целиком** — до повторного клика или до закрытия панели. Следующий её показ @@ -240,12 +253,13 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 ## Как считается план План раскладывает 100 % недельного лимита **по рабочим часам**, а не по -астрономическим. Рабочий день задаётся в настройках (по умолчанию 11:00 → -00:00); ночью линия плана стоит. Отсюда простое правило: **факт ниже плана — +астрономическим. Рабочий день задаётся в настройках (по умолчанию 10:00 → +18:00); ночью линия плана стоит. Отсюда простое правило: **факт ниже плана — идёте в графике, выше — сгорите раньше пятницы.** -Ряд для рабочего дня 11:00 → 00:00 и окна «ПТ 16:00 → ПТ 16:00» (московское -время — на час меньше). Всего в неделе 91 рабочий час: +Ниже — ряд для примера с рабочим днём 11:00 → 00:00 (13 рабочих часов в +сутки, круглое число для наглядного счёта) и окна «ПТ 16:00 → ПТ 16:00» +(московское время — на час меньше). Всего в неделе 91 рабочий час: | Строка | Часы в окне | Рабочих часов | Бюджет строки | План к её концу | |---|---|---|---|---| @@ -361,11 +375,25 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 |---|---| | Язык | «Как в системе» (по умолчанию), «Русский» или «English»; применяется сразу, без перезапуска | | Запускать при входе в систему | автозапуск: заводит или сносит launchd-агента | -| Источник данных | `auto` — официальный с падением на локальный, `official` — только сервер, `local` — только оценка по транскриптам | +| Источник данных | `auto` — всегда онлайн, с падением на локальный, `official` — только сервер, `local` — только оценка по транскриптам | | Обновлять раз в | интервал опроса, 1–30 минут (в сеть — не чаще раза в 60 с) | -| Начало недели | с какого дня идёт ряд суток на панели: «Понедельник» (по умолчанию) или «День сброса». Момент сброса это не двигает — его называет сервер; под настройкой показан тот, что придёт следующим | -| Таймзона | в какой зоне считать границы суток и в какой показывать момент сброса | -| Рабочий день | распорядок из списка («Рабочий день 10–18», «Длинный день 10–22», «Вечерний 11–24», «С утра до ночи 9–22», «Круглосуточно») или свои часы степперами; по ним раскладывается недельный лимит, ночью план стоит. Строка «Получается» показывает выбранное словами: «11:00 – полуночи, 13 ч в сутки» | + +Дневной план и всё, что с ним связано, собрано под раскрывающимся пунктом +**«Расширенные настройки»** внизу вкладки — свёрнут по умолчанию, раскрывается +щелчком в любом месте строки заголовка: + +| Настройка | Что делает | +|---|---| +| Дневной план | по умолчанию выключен: панель показывает только пятичасовую сессию и итог недели, без разбора по дням (см. [Без дневного плана](#без-дневного-плана)) | +| Показывать | видно только при включённом плане: «Вся неделя» — семь строк, «Только сегодня» — одна, а неделя раскрывается кликом по ней, пока панель открыта | +| Начало недели | видно только при включённом плане: с какого дня идёт ряд суток на панели — «Понедельник» (по умолчанию) или «День сброса». Момент сброса это не двигает — его называет сервер; под настройкой показан тот, что придёт следующим | +| Таймзона | в какой зоне считать границы суток и в какой показывать момент сброса — видно всегда, влияет и на пятичасовую сессию | +| Рабочий день | распорядок из списка («Рабочий день 10–18» — по умолчанию, «Длинный день 10–22», «Вечерний 11–24», «С утра до ночи 9–22», «Круглосуточно») или свои часы степперами; видно всегда — по нему раскладывается и недельный план, и плановая зона в значке строки меню, ночью план стоит. Строка «Получается» показывает выбранное словами: «10:00 – 18:00, 8 ч в сутки» | + +«Показывать» и «Начало недели» появляются только при включённом «Дневном +плане» — без разбора по дням делить и переставлять нечего. «Таймзона» и +«Рабочий день» остаются на месте и без него: рабочие часы по-прежнему +двигают план в значке строки меню и прогноз «кончится в …». «Как в системе» означает русский на русской macOS и английский на любой другой: язык берётся из первого в списке предпочитаемых языков системы, и всё, @@ -388,6 +416,12 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 | Значок | полоса с процентом, только полоса или кольцо с двумя лимитами | | Заполнять дугой | только для кольца: что показывает дуга — пятичасовую сессию (по умолчанию) или недельный лимит. Второй лимит достаётся цифре в центре | | Менять цвет по порогам | выключенный — значок всегда нейтрального цвета, расход виден заполнением и цифрой; панель красится в любом случае | + +Сами пороги — под раскрывающимся пунктом **«Расширенные настройки»** внизу +вкладки (свёрнут по умолчанию): + +| Настройка | Что делает | +|---|---| | Недельный лимит | после скольких процентов желтеет и краснеет (по умолчанию 81 и 93) | | Пятичасовая сессия | то же для сессии — свои пороги, независимые от недельных (по умолчанию 81 и 95) | @@ -418,7 +452,6 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 | Прозрачный фон | материал системного меню с размытием либо сплошная заливка палитры | | Плотность фона | 0 — чистый материал, как у системных меню; 1 — плотная вуаль, сквозь которую размытие ещё видно | | Скругление углов | 0–24 pt | -| Суточные полосы | «Вся неделя» — семь строк; «Только сегодня» — одна, а неделя раскрывается кликом по ней, пока панель открыта | | Пятичасовая сессия | показывать ли строку сессии | | Прогноз | вторая строка футера «при таком темпе кончится …» | | Сброс сессии | «через 1 ч 12 мин», «в 14:35» или и то, и другое | @@ -438,10 +471,16 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 |---|---| | Предупреждать о приближении к лимиту | общий выключатель: гасит баннеры обоих лимитов, не трогая их пороги | | Со звуком | звук вместе с баннером; выключенный — уведомление приходит молча и ждёт в Центре уведомлений | +| Сообщать о новой версии | баннер, когда на GitHub вышел релиз новее вашего. Не гаснет вместе с общим выключателем: тот про разговоры о расходе, а это новость другого рода | + +Пороги обоих лимитов — под раскрывающимся пунктом **«Расширенные настройки»** +внизу вкладки (свёрнут по умолчанию): + +| Настройка | Что делает | +|---|---| | Показать пример | своя кнопка в каждой секции лимита: шлёт такой же баннер, как настоящий, с первым порогом этого лимита вместо живого расхода. На историю сказанного не влияет | | Недельный лимит | уведомлять ли о неделе и после скольких процентов — два порога, по умолчанию 80 и 95. В баннере приходит красным числом | | Пятичасовая сессия | то же для сессии, по умолчанию 75 и 95. В баннере приходит дугой | -| Сообщать о новой версии | баннер, когда на GitHub вышел релиз новее вашего. Не гаснет вместе с общим выключателем: тот про разговоры о расходе, а это новость другого рода | Пороги здесь свои, отдельные от цветовых со вкладки «Строка меню», и это не дублирование: цвет замечают, только посмотрев на часы, а баннер приходит @@ -455,9 +494,11 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 ### Доступ -Объяснение, откуда берётся токен, почему вставить свой нельзя и как обойтись -без него вовсе, плюс кнопка «Проверить сейчас»: она делает один настоящий -запрос и печатает результат — процент или причину отказа. +Короткая версия того, что подробно расписано в +[Авторизация и доступ](#авторизация-и-доступ) в начале этого файла: откуда +берётся токен и что с ним можно и нельзя сделать, плюс кнопка «Проверить +сейчас» — она делает один настоящий запрос и печатает результат: процент или +причину отказа. Сверху — **«Сейчас в ключе»**: метка аккаунта из записи Keychain, начало UUID организации и тариф (`7f3a1b2c·max`). Токен обновляется раз в час, а метка @@ -465,7 +506,10 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 В режиме «только локальная оценка» строка пустая: там Keychain не читается вовсе, и ради одной подписи нарушать это обещание незачем. -Рядом — **«Начать счёт заново»** (см. [Смена аккаунта](#смена-аккаунта)). +Рядом — **«Начать счёт заново»** (см. [Смена аккаунта](#смена-аккаунта)). Как +работать вовсе без токена и что делать, если macOS снова спросила доступ, — +в [Авторизация и доступ](#авторизация-и-доступ): в самом окне эти два сюжета +были повторением документации, а не настройкой, и переехали сюда целиком. ### О программе @@ -609,7 +653,7 @@ GitHub — запись в лог, и ничего поверх работы. | `timeZone` | `""` | таймзона окна, границ суток и подписи сброса; пустая строка — системная | | `refreshInterval` | `300` | секунды между обновлениями. Меньше 30 чинится на 30, ползунок в настройках начинается с 60. В сеть при этом ходят не чаще раза в минуту в любом случае — короткий интервал только чаще перерисовывает панель по уже полученному числу | | `provider` | `auto` | `official`, `local` или `auto` | -| `workHours.start` / `.end` | `11` / `24` | рабочий день, по которому раскладывается лимит; `24` — полночь. `0`/`24` — считать круглосуточно, как раньше | +| `workHours.start` / `.end` | `10` / `18` | рабочий день, по которому раскладывается лимит; `24` — полночь. `0`/`24` — считать круглосуточно, как раньше | | `menuBarStyle` | `ring` | `percent`, `compact` (только полоска) или `ring` (кольцо с процентом) | | `ringArc` | `session` | что заполняет дугу кольца: `session` или `week`; второй лимит уходит на цифру внутри | | `weeklyBudget` | `0` | условная стоимость недели для офлайн-оценки; ставится калибровкой | @@ -628,6 +672,7 @@ GitHub — запись в лог, и ничего поверх работы. | `appearance.transparentPanel` | `true` | материал с размытием или сплошной фон | | `appearance.panelTintOpacity` | `0.6` | плотность вуали поверх материала, 0…1 | | `appearance.cornerRadius` | `16` | скругление углов панели, pt | +| `appearance.showsPlan` | `false` | `true` включает разбор по дням: семь суточных полос с планом вместо одной полосы недели; без него `panelLayout` не смотрят | | `appearance.panelLayout` | `compact` | `week` — семь суточных полос, `compact` — только текущие сутки (неделя раскрывается кликом по строке дня) | | `appearance.showSession` / `.showForecast` | `true` / `false` | показывать строку сессии и прогноз | | `appearance.sessionReset` | `both` | подпись сброса сессии: `relative` («через 1 ч 12 мин»), `absolute` («в 14:35») или `both` | diff --git a/docs/images/panel-contrast-dark.png b/docs/images/panel-contrast-dark.png index f7b1a79..e647b2b 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 d37f5f9..04fcb91 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 63e201e..63c3cad 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 0b365bd..76e18af 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 a8280cd..c2479a1 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 213afa5..cfe90ea 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 a3c191b..81c62d1 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 6ecdd6b..d4fc552 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-models-dark.png b/docs/images/panel-models-dark.png index 99e5d53..ebbf3e6 100644 Binary files a/docs/images/panel-models-dark.png and b/docs/images/panel-models-dark.png differ diff --git a/docs/images/panel-models-light.png b/docs/images/panel-models-light.png index e32e88a..7e43db1 100644 Binary files a/docs/images/panel-models-light.png and b/docs/images/panel-models-light.png differ diff --git a/docs/images/panel-paper-dark.png b/docs/images/panel-paper-dark.png index c389323..6dd0bb3 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 c389323..6dd0bb3 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 63e201e..63c3cad 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 213afa5..cfe90ea 100644 Binary files a/docs/images/panel-system-light.png and b/docs/images/panel-system-light.png differ diff --git a/docs/images/settings-notifications-dark.png b/docs/images/settings-notifications-dark.png index 9cf8014..259c59e 100644 Binary files a/docs/images/settings-notifications-dark.png and b/docs/images/settings-notifications-dark.png differ diff --git a/docs/images/settings-notifications-light.png b/docs/images/settings-notifications-light.png index 495a7ce..0a8b53f 100644 Binary files a/docs/images/settings-notifications-light.png and b/docs/images/settings-notifications-light.png differ