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
47 changes: 36 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ is the place for the shorter, human-facing story.

## [Unreleased]

### Changed

- New app icon: the ring from the menu bar, drawn large on an ink-coloured
tile — green from 0 to 80 %, amber to 90 %, red closing the circle to 100 %,
in the same colours the panel uses. The old icon was three week bars, and
below 32 pt they merged into a green smudge; the ring survives down to 16 pt,
which is the size Spotlight and the "Open with" list show. Thresholds of the
icon are deliberately not the notification defaults (80 / 95): five per cent
of a circle is one pixel at that size, and red would not be visible at all.
- Notification screenshots in the README and the guide retaken — the banners
carry the app icon, so both showed the old one. The README now opens with the
icon as well (`docs/images/app-icon.png`).

## [0.1.9] — 2026-08-16

### Added

- Notifications when spend crosses a threshold you set: two thresholds per
Expand All @@ -40,6 +55,24 @@ is the place for the shorter, human-facing story.
- `Formatting.longDuration` — the same interval in words ("2 дня 4 часа",
"1 час 12 минут"), with Russian pluralisation. The panel keeps the short form;
a banner has room for the long one.

### Notes

- Notifications are on out of the box: a feature that stays silent until you
find its tab does not do the job it was added for. macOS asks for permission
once, on the first launch of the new version; if it is refused, the tab says
so and offers the only place the ban can be lifted — System Settings. A build
run straight from `swift run` has no notifications at all: macOS identifies an
app by its bundle, and there isn't one.
- Session notifications need the official source. The local estimate does not
compute the 5-hour percentage at all, so offline there is nothing to warn
about — and an expired session stays quiet rather than warning off a cached
number that reset hours ago.

## [0.1.8] — 2026-08-15

### Added

- Model breakdown in the panel: clicking the percentage — on a day row or on
the 5-hour session row — replaces the day rows with one row per model (Opus,
Sonnet, Haiku), each with the same kind of bar showing its share of the
Expand All @@ -63,16 +96,6 @@ is the place for the shorter, human-facing story.

### Notes

- Notifications are on out of the box: a feature that stays silent until you
find its tab does not do the job it was added for. macOS asks for permission
once, on the first launch of the new version; if it is refused, the tab says
so and offers the only place the ban can be lifted — System Settings. A build
run straight from `swift run` has no notifications at all: macOS identifies an
app by its bundle, and there isn't one.
- Session notifications need the official source. The local estimate does not
compute the 5-hour percentage at all, so offline there is nothing to warn
about — and an expired session stays quiet rather than warning off a cached
number that reset hours ago.
- The breakdown is computed from local transcripts in `~/.claude/projects` and
weighted by model prices, so it is an estimate — which the panel states
plainly while the breakdown is on screen. The official `/api/oauth/usage`
Expand Down Expand Up @@ -175,7 +198,9 @@ seven days of the week window, the 5-hour session, official numbers from
`/api/oauth/usage` with a local estimate as fallback, settings window, MIT
licence, CI, and a disk image built by tag for Apple Silicon.

[Unreleased]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.7...HEAD
[Unreleased]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.9...HEAD
[0.1.9]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.4...v0.1.5
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<p align="center">
<img src="docs/images/app-icon.png" width="128" alt="Иконка ClaudeWeek: кольцо недели">
</p>

# ClaudeWeek

[![CI](https://github.com/Greem4/ClaudeWeek/actions/workflows/ci.yml/badge.svg)](https://github.com/Greem4/ClaudeWeek/actions/workflows/ci.yml)
Expand Down
159 changes: 99 additions & 60 deletions Sources/ClaudeWeekApp/AppIcon.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import AppKit

/// Иконка приложения — та же метафора, что и в панели: три полосы недели,
/// где зелёная упирается в янтарную. Рисуем кодом, потому что без Xcode
/// собрать ассет-каталог нечем.
/// Иконка приложения — то же кольцо, что висит в строке меню, только крупно:
/// шкала недельного лимита, разложенная по порогам. Рисуем кодом, потому что
/// без Xcode собрать ассет-каталог нечем.
///
/// Цвета берутся из родной палитры, тёмной её половины: иконка на плитке
/// Launchpad живёт своей жизнью и на тему системы не смотрит, но роли у полос
/// Цвета дуг берутся из родной палитры, тёмной её половины: иконка на плитке
/// Launchpad живёт своей жизнью и на тему системы не смотрит, но роли у цветов
/// те же, что в панели, — и расходиться их оттенки не должны.
enum AppIcon {
private static let ink = Palette.system

/// Где зелёное сменяется янтарным и янтарное — красным. Числа не совпадают
/// с порогами уведомлений (80 и 95 по умолчанию) намеренно: на 16 pt пять
/// процентов круга вырождаются в одну точку, и красного в знаке попросту
/// не видно. Тридцать шесть градусов — минимум, который переживает
/// уменьшение до иконки в Spotlight.
private static let warningStart: CGFloat = 80
private static let criticalStart: CGFloat = 90

/// Плитка. Своих цветов у неё в палитре нет: фон панели рассчитан на текст
/// поверх материала, а плитке нужен градиент, который читается на любом
/// рабочем столе. Холодный графит выбран под кольцо — тёплый серый рядом с
/// зелёным желтит.
private static let plateTop: UInt32 = 0x2A2E38
private static let plateBottom: UInt32 = 0x111318

/// Пары «имя файла в .iconset — сторона в пикселях».
static let variants: [(name: String, pixels: Int)] = [
("icon_16x16", 16), ("icon_16x16@2x", 32),
Expand Down Expand Up @@ -48,68 +63,92 @@ enum AppIcon {

private static func draw(side: CGFloat) -> NSImage {
NSImage(size: NSSize(width: side, height: side), flipped: false) { _ in
// macOS сама не скругляет иконку — рисуем «сквиркл» вручную,
// с отступом по гайдлайну (примерно десятая часть стороны).
let inset = side * 0.08
let plate = NSRect(x: inset, y: inset, width: side - inset * 2, height: side - inset * 2)
let corner = plate.width * 0.22
let plate = plateRect(side: side)
fillPlate(plate)
drawRing(in: plate)
return true
}
}

NSColor(hex: ink.panelBackground.dark).setFill()
NSBezierPath(roundedRect: plate, xRadius: corner, yRadius: corner).fill()
/// Отступ по гайдлайну: плитка не занимает квадрат целиком.
private static func plateRect(side: CGFloat) -> NSRect {
let inset = side * 0.055
return NSRect(x: inset, y: inset, width: side - inset * 2, height: side - inset * 2)
}

let barHeight = plate.height * 0.11
let spacing = plate.height * 0.10
let left = plate.minX + plate.width * 0.14
let usable = plate.width * 0.72
let radius = barHeight / 2
let totalHeight = barHeight * 3 + spacing * 2
var y = plate.midY + totalHeight / 2 - barHeight
private static func fillPlate(_ rect: NSRect) {
NSGraphicsContext.current?.saveGraphicsState()
defer { NSGraphicsContext.current?.restoreGraphicsState() }

// Сверху вниз: недобор, ровно по плану, перерасход.
let rows: [(fill: Double, plan: Double)] = [
(0.45, 0.70),
(0.70, 0.70),
(0.95, 0.70),
]
squircle(in: rect).addClip()
NSGradient(colors: [NSColor(hex: plateTop), NSColor(hex: plateBottom)])?
.draw(in: rect, angle: -90)
// Блик по верхней кромке: без него плитка выглядит наклейкой, а не
// иконкой рядом с системными.
NSGradient(colors: [NSColor(white: 1, alpha: 0.14), NSColor(white: 1, alpha: 0)])?
.draw(
in: NSRect(
x: rect.minX, y: rect.maxY - rect.height * 0.4,
width: rect.width, height: rect.height * 0.4
),
angle: -90
)
}

for row in rows {
NSColor(hex: ink.track.dark).setFill()
NSBezierPath(
roundedRect: NSRect(x: left, y: y, width: usable, height: barHeight),
xRadius: radius, yRadius: radius
).fill()
/// Форма плитки macOS — суперэллипс, а не прямоугольник со скруглением:
/// у скруглённого угол ломается там, где дуга встречается с прямой, и
/// рядом с системными иконками это видно.
private static func squircle(in rect: NSRect, exponent: CGFloat = 5) -> NSBezierPath {
let path = NSBezierPath()
let a = rect.width / 2, b = rect.height / 2
let steps = 720
for step in 0...steps {
let t = CGFloat(step) / CGFloat(steps) * 2 * .pi
let cosT = cos(t), sinT = sin(t)
let point = NSPoint(
x: rect.midX + a * copysign(pow(abs(cosT), 2 / exponent), cosT),
y: rect.midY + b * copysign(pow(abs(sinT), 2 / exponent), sinT)
)
if step == 0 { path.move(to: point) } else { path.line(to: point) }
}
path.close()
return path
}

NSColor(hex: ink.plan.dark).setFill()
NSBezierPath(
roundedRect: NSRect(x: left, y: y, width: usable * row.plan, height: barHeight),
xRadius: radius, yRadius: radius
).fill()
private static func drawRing(in plate: NSRect) {
let center = NSPoint(x: plate.midX, y: plate.midY)
let radius = plate.width * 0.305
let width = plate.width * 0.165

NSColor(hex: ink.good.dark).setFill()
NSBezierPath(
roundedRect: NSRect(
x: left, y: y,
width: usable * min(row.fill, row.plan), height: barHeight
),
xRadius: radius, yRadius: radius
).fill()
// Соседние дуги перекрываются на полградуса: встык AppKit оставляет
// волосяную щель, и на мелком размере она читается как грязь.
let bleed: CGFloat = 0.5
let warningAngle = warningStart * 3.6
let criticalAngle = criticalStart * 3.6

if row.fill > row.plan {
let gap = max(side * 0.012, 1)
let start = left + usable * row.plan + gap
NSColor(hex: ink.warning.dark).setFill()
NSBezierPath(
roundedRect: NSRect(
x: start, y: y,
width: max(left + usable * row.fill - start, 1), height: barHeight
),
xRadius: radius, yRadius: radius
).fill()
}
arc(center: center, radius: radius, width: width,
from: 90, sweep: warningAngle + bleed, color: ink.good.dark)
arc(center: center, radius: radius, width: width,
from: 90 - warningAngle, sweep: criticalAngle - warningAngle + bleed,
color: ink.warning.dark)
arc(center: center, radius: radius, width: width,
from: 90 - criticalAngle, sweep: 360 - criticalAngle, color: ink.critical.dark)
}

y -= barHeight + spacing
}
return true
}
/// 12 часов — верх круга; в системе координат AppKit это 90°, дальше по
/// часовой стрелке, как заполняется кольцо в строке меню.
private static func arc(
center: NSPoint, radius: CGFloat, width: CGFloat,
from: CGFloat, sweep: CGFloat, color: UInt32
) {
let path = NSBezierPath()
path.appendArc(
withCenter: center, radius: radius,
startAngle: from, endAngle: from - min(sweep, 359.9), clockwise: true
)
path.lineWidth = width
path.lineCapStyle = .butt
NSColor(hex: color).setStroke()
path.stroke()
}
}
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ SwiftUI` — значит, расчёт просочился в UI или нао
| `NotificationController.swift` | уведомления глазами системы: разрешение macOS, отправка баннеров, лог сказанного на диске |
| `AlertArtwork.swift` | картинка баннера: дуга для сессии, красное число для недели |
| `Screenshot.swift` | `--screenshot`: панель во всех темах, она же в разбивке по моделям, вкладка уведомлений, иконка в PNG |
| `AppIcon.swift` | `--icon`: `.iconset` для сборки бандла |
| `AppIcon.swift` | `--icon`: кольцо недели по порогам, `.iconset` для сборки бандла |
| `CLI.swift` | `--json`, `--calibrate` и `--update` |

### Потоки данных
Expand Down
Binary file added docs/images/app-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/notification-session-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/notification-week-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.