Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,36 @@

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

### Добавлено

- Настройка **«Начало недели»**: ряд суток на панели идёт либо с понедельника
(по умолчанию), либо с дня сброса, как раньше. При понедельнике у ряда своя
шкала: понедельник открывается нулём, пятница приходит к 100 % в момент
сброса. 100 % здесь — не весь недельный лимит, а то, что осталось к утру
понедельника: по нему раскладывается план, от него же считается факт.
Суббота с воскресеньем, прошедшие сразу после сброса, уходят под черту в
конец ряда и остаются в недельных процентах — они и правда съели свою долю
лимита. Итог недели в футере тоже недельный. Границы окна и момент сброса
настройка не трогает.

### Изменено

- Заводская таймзона — системная, а не `Europe/Saratov`. Зона, в которой
писалась программа, попадала на чужую машину дефолтом, и сутки на панели
переключались там не в местную полночь. У кого зона уже выбрана руками,
ничего не меняется.
- Полей «День / час / минута сброса» в настройках больше нет. Момент сброса
приходит от сервера (`resets_at`), а офлайн проецируется от последнего, им
же названного, — поля не влияли ни на что и читались как сломанные: их
крутили, ждали, что неделя сдвинется, и не понимали, почему ничего не
меняется. Вместо них под «Началом недели» показан настоящий момент сброса в
выбранной зоне. В `config.json` ключи остались: по ним строится окно, если
сервер не отвечал ещё ни разу и кеш пуст.

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

- В строке состояния на вкладке «О программе» больше нет «у вас»: просто
«последняя версия — 0.1.12, проверено в 13:17».
- Разовый сбой GitHub API больше не отменяет выпуск. Список файлов PR
спрашивается с тремя попытками: 17 августа GitHub был в частичной аварии и
ответил на этот запрос `HTTP 503` ровно в момент слияния — шаг упал, и версия
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Code сбрасывает каждым обновлением токена, и
- **Неделя с планом** — семь суточных полос: факт, плановая зона, перерасход.
100 % лимита раскладываются по **рабочим часам** (по умолчанию 11:00 → 00:00),
а не по астрономическим: сон не должен весить столько же, сколько работа.
Ряд идёт с понедельника, а сутки, прошедшие сразу после сброса, стоят под
чертой в конце; в настройках его можно развернуть от дня сброса.
- **Пятичасовую сессию** — отдельной строкой над сутками, с часом сброса. Тот
лимит, в который упираются чаще недельного.
- **Прогноз** — «при таком темпе кончится ПТ 15:57», тоже по рабочим часам.
Expand Down
33 changes: 21 additions & 12 deletions Sources/ClaudeWeekApp/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
)
}

Expand Down
7 changes: 5 additions & 2 deletions Sources/ClaudeWeekApp/PanelModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/// Показывать весь недельный ряд: так настроено или так раскрыли кликом.
Expand Down
10 changes: 9 additions & 1 deletion Sources/ClaudeWeekApp/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions Sources/ClaudeWeekApp/Screenshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ enum Screenshot {
// рабочий день оставляем из конфига: от них зависят подписи строк.
config.appearance = AppearanceConfig()
config.thresholds = Thresholds()
// Порядок строк — тоже вид, а не окно: картинки должны показывать
// заводскую неделю от понедельника, чей бы конфиг ни лежал рядом.
config.weekStart = Config.default.weekStart
// Единственное отступление от заводского вида: недельный ряд. Из
// коробки панель компактная, но картинка в README должна показывать
// то, ради чего программа написана, — все семь суток разом. Как
Expand Down
30 changes: 6 additions & 24 deletions Sources/ClaudeWeekApp/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
30 changes: 29 additions & 1 deletion Sources/ClaudeWeekApp/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ final class SettingsModel {
/// в кеше, а не в конфиге, поэтому вкладка «О программе» читает его
/// отдельно — иначе она показывала бы «не подобран» даже после калибровки.
private(set) var pickedBudget: Double?
/// Момент недельного сброса, названный сервером. Живёт в кеше, а не в
/// конфиге: настройками его не задать — и именно поэтому его нужно
/// показать. Человек, искавший в настройках «день сброса», иначе крутит
/// поля и гадает, почему неделя начинается не тогда, когда он поставил.
private(set) var officialReset: Date?
/// Автозапуск тоже мимо конфига: он и есть launchd-агент, и правда о нём
/// одна — лежит плист или нет.
private(set) var launchAtLogin = LoginItem.isEnabled
Expand Down Expand Up @@ -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
// Разрешение на уведомления снимают там же, где выдали, — в системных
// настройках, мимо этого окна. Спрашиваем систему на каждый показ.
Expand Down Expand Up @@ -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."
)
}

/// «Показать пример» с вкладки уведомлений: тот же баннер, что придёт
/// по-настоящему, — по одному на каждый лимит. Идёт мимо конфига:
/// показанному баннеру нечего сохранять.
Expand Down
4 changes: 1 addition & 3 deletions Sources/ClaudeWeekApp/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
// Интервал живёт в таймере, а не в провайдере: не перезапустив таймер,
Expand Down
4 changes: 2 additions & 2 deletions Sources/ClaudeWeekApp/UpdateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
Loading