From e3b6727a7914d766da2f76edcd4df0cf75e3cde7 Mon Sep 17 00:00:00 2001 From: justice-hwan Date: Wed, 3 Jun 2026 01:34:04 +0900 Subject: [PATCH 1/3] feat: help users pace usage before reset Adds a compact guide marker to each live usage window so users can compare current usage against the even-spend target implied by the reset deadline. Constraint: Preserve the compact notch panel height and existing chart-style selector. Rejected: Add a separate settings-controlled pace page | too much surface area for the issue request. Confidence: high Scope-risk: narrow Directive: Keep pace math tied to provider reset timestamps; do not infer limits without reset data. Tested: ./scripts/verify.sh --- Resources/en.lproj/Localizable.strings | 2 + Resources/zh-Hans.lproj/Localizable.strings | 2 + Sources/Views/UsageView.swift | 95 ++++++++++++++++++--- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index d594f112..ddc3c351 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -136,6 +136,7 @@ "%@: no data for 5-hour window" = "%@: no data for 5-hour window"; "Click to expand. Command-click to cycle visualization." = "Click to expand. Command-click to cycle visualization."; "Command-click to cycle visualization." = "Command-click to cycle visualization."; +"guide %d%%" = "guide %d%%"; "Hover to peek usage. Click to expand. Command-click to cycle visualization." = "Hover to peek usage. Click to expand. Command-click to cycle visualization."; "How often to refresh." = "How often to refresh."; "Keep the percentage and time remaining visible without hovering." = "Keep the percentage and time remaining visible without hovering."; @@ -145,3 +146,4 @@ "resets in %d minutes" = "resets in %d minutes"; "Swipe to change pages." = "Swipe to change pages."; "Tightens the gap between logos when the island is on a screen without a hardware notch." = "Tightens the gap between logos when the island is on a screen without a hardware notch."; +"used %d%%, guide %d%%" = "used %d%%, guide %d%%"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 37141e75..a9d96c0f 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -136,6 +136,7 @@ "%@: no data for 5-hour window" = "%@:5 小时窗口暂无数据"; "Click to expand. Command-click to cycle visualization." = "点击展开。Command 点击可切换可视化。"; "Command-click to cycle visualization." = "Command 点击可切换可视化。"; +"guide %d%%" = "建议 %d%%"; "Hover to peek usage. Click to expand. Command-click to cycle visualization." = "悬停预览用量。点击展开。Command 点击可切换可视化。"; "How often to refresh." = "刷新频率。"; "Keep the percentage and time remaining visible without hovering." = "无需悬停即可始终查看用量百分比和剩余时间。"; @@ -145,3 +146,4 @@ "resets in %d minutes" = "%d 分钟后重置"; "Swipe to change pages." = "滑动切换页面。"; "Tightens the gap between logos when the island is on a screen without a hardware notch." = "在没有硬件刘海的屏幕上收紧 logo 之间的间距。"; +"used %d%%, guide %d%%" = "已用 %d%%,建议 %d%%"; diff --git a/Sources/Views/UsageView.swift b/Sources/Views/UsageView.swift index 80a744aa..ed1619f5 100644 --- a/Sources/Views/UsageView.swift +++ b/Sources/Views/UsageView.swift @@ -152,25 +152,49 @@ struct ChartTile: View { let sub = subCaption() let label = L10n.tr(labelKey) - Group { - switch style { - case .ring: RingChart(value: value, color: color, label: label, sub: sub) - case .bar: BarChart(value: value, color: color, label: label, sub: sub) - case .stepped: SteppedChart(value: value, color: color, label: label, sub: sub) - case .numeric: NumericChart(value: value, color: color, label: label, sub: compactSubCaption()) - case .spark: SparkChart(value: value, color: color, label: label, sub: sub, seed: seed) + VStack(alignment: .leading, spacing: 5) { + Group { + switch style { + case .ring: RingChart(value: value, color: color, label: label, sub: sub) + case .bar: BarChart(value: value, color: color, label: label, sub: sub) + case .stepped: SteppedChart(value: value, color: color, label: label, sub: sub) + case .numeric: NumericChart(value: value, color: color, label: label, sub: compactSubCaption()) + case .spark: SparkChart(value: value, color: color, label: label, sub: sub, seed: seed) + } } + .id(style) + // Blur + scale + opacity, all on the same strong ease-out at 220ms. + // The blur masks the geometric mismatch between Ring and Bar so the + // crossfade reads as one morph instead of two stacked objects. + .transition(.chartSwap.animation(.chartSwap)) + + PaceGuideBar(guide: paceGuide(), color: color) } - .id(style) - // Blur + scale + opacity, all on the same strong ease-out at 220ms. - // The blur masks the geometric mismatch between Ring and Bar so the - // crossfade reads as one morph instead of two stacked objects. - .transition(.chartSwap.animation(.chartSwap)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(height: Self.tileHeight) .accessibilityElement(children: .combine) .accessibilityLabel(L10n.tr("%@, %d%%", label, Int(value))) - .accessibilityValue(subCaption()) + .accessibilityValue(accessibilityValue()) + } + + private func paceGuide() -> PaceGuide? { + guard window.error == nil, + let resetAt = window.resetAt + else { return nil } + + let duration: TimeInterval = labelKey == "week" + ? 7 * 24 * 60 * 60 + : 5 * 60 * 60 + let remaining = resetAt.timeIntervalSinceNow + let target = min(1, max(0, 1 - remaining / duration)) + return PaceGuide(actualPercent: window.usedPercent, targetPercent: target) + } + + private func accessibilityValue() -> String { + guard let guide = paceGuide() else { return subCaption() } + return L10n.tr("used %d%%, guide %d%%", + window.percentInt, + Int((guide.targetPercent * 100).rounded())) } private func subCaption() -> String { @@ -213,3 +237,48 @@ struct ChartTile: View { return "" } } + +private struct PaceGuide { + let actualPercent: Double + let targetPercent: Double +} + +private struct PaceGuideBar: View { + let guide: PaceGuide? + let color: Color + + var body: some View { + Group { + if let guide { + VStack(alignment: .leading, spacing: 2) { + GeometryReader { geo in + let actualX = geo.size.width * CGFloat(guide.actualPercent) + let targetX = geo.size.width * CGFloat(guide.targetPercent) + + ZStack(alignment: .leading) { + Capsule() + .fill(.white.opacity(0.055)) + .frame(height: 3) + Capsule() + .fill(color.opacity(0.72)) + .frame(width: actualX, height: 3) + .animation(.strongEaseOut, value: guide.actualPercent) + Rectangle() + .fill(.white.opacity(0.72)) + .frame(width: 1, height: 8) + .offset(x: targetX) + .animation(.strongEaseOut, value: guide.targetPercent) + } + } + .frame(height: 8) + + Text(L10n.tr("guide %d%%", Int((guide.targetPercent * 100).rounded()))) + .font(Typography.caption) + .foregroundStyle(.white.opacity(0.34)) + .lineLimit(1) + } + .accessibilityHidden(true) + } + } + } +} From e5acfc97e31b874214b9f3518a3bce69d0af75e9 Mon Sep 17 00:00:00 2001 From: justice-hwan Date: Wed, 3 Jun 2026 01:56:12 +0900 Subject: [PATCH 2/3] feat: make pace guides optional Fold the reset-window pace guide into each chart style and add a General preference so users can opt in without changing the default UI. Constraint: Default must remain off so existing users keep the original chart appearance after upgrade. Rejected: Always show a separate guide bar | it cluttered Numeric and Sparkline chart styles. Confidence: high Scope-risk: narrow Directive: Keep guide markers hidden unless Pace guide is enabled and reset timestamps are available. Tested: ./scripts/verify.sh --- Resources/en.lproj/Localizable.strings | 3 +- Resources/zh-Hans.lproj/Localizable.strings | 3 +- Sources/Model/PaceGuideStore.swift | 20 +++++ Sources/Views/Charts/BarChart.swift | 8 ++ Sources/Views/Charts/NumericChart.swift | 8 ++ Sources/Views/Charts/RingChart.swift | 35 ++++++++ Sources/Views/Charts/SparkChart.swift | 19 +++-- Sources/Views/Charts/SteppedChart.swift | 36 +++++--- Sources/Views/SettingsView.swift | 9 ++ Sources/Views/UsageView.swift | 93 ++++++--------------- 10 files changed, 148 insertions(+), 86 deletions(-) create mode 100644 Sources/Model/PaceGuideStore.swift diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index ddc3c351..567a8a65 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -136,14 +136,15 @@ "%@: no data for 5-hour window" = "%@: no data for 5-hour window"; "Click to expand. Command-click to cycle visualization." = "Click to expand. Command-click to cycle visualization."; "Command-click to cycle visualization." = "Command-click to cycle visualization."; -"guide %d%%" = "guide %d%%"; "Hover to peek usage. Click to expand. Command-click to cycle visualization." = "Hover to peek usage. Click to expand. Command-click to cycle visualization."; "How often to refresh." = "How often to refresh."; "Keep the percentage and time remaining visible without hovering." = "Keep the percentage and time remaining visible without hovering."; "Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1." = "Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1."; "Preview" = "Preview"; +"Pace guide" = "Pace guide"; "resets in %d hours" = "resets in %d hours"; "resets in %d minutes" = "resets in %d minutes"; +"Show a small marker for expected usage before reset." = "Show a small marker for expected usage before reset."; "Swipe to change pages." = "Swipe to change pages."; "Tightens the gap between logos when the island is on a screen without a hardware notch." = "Tightens the gap between logos when the island is on a screen without a hardware notch."; "used %d%%, guide %d%%" = "used %d%%, guide %d%%"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index a9d96c0f..65d9dc0e 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -136,14 +136,15 @@ "%@: no data for 5-hour window" = "%@:5 小时窗口暂无数据"; "Click to expand. Command-click to cycle visualization." = "点击展开。Command 点击可切换可视化。"; "Command-click to cycle visualization." = "Command 点击可切换可视化。"; -"guide %d%%" = "建议 %d%%"; "Hover to peek usage. Click to expand. Command-click to cycle visualization." = "悬停预览用量。点击展开。Command 点击可切换可视化。"; "How often to refresh." = "刷新频率。"; "Keep the percentage and time remaining visible without hovering." = "无需悬停即可始终查看用量百分比和剩余时间。"; "Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1." = "注入测试百分比。仅在使用 CODEXISLAND_DEBUG=1 启动时显示。"; "Preview" = "预览"; +"Pace guide" = "进度参考线"; "resets in %d hours" = "%d 小时后重置"; "resets in %d minutes" = "%d 分钟后重置"; +"Show a small marker for expected usage before reset." = "显示重置前预期用量的小标记。"; "Swipe to change pages." = "滑动切换页面。"; "Tightens the gap between logos when the island is on a screen without a hardware notch." = "在没有硬件刘海的屏幕上收紧 logo 之间的间距。"; "used %d%%, guide %d%%" = "已用 %d%%,建议 %d%%"; diff --git a/Sources/Model/PaceGuideStore.swift b/Sources/Model/PaceGuideStore.swift new file mode 100644 index 00000000..7ebc3d97 --- /dev/null +++ b/Sources/Model/PaceGuideStore.swift @@ -0,0 +1,20 @@ +import Foundation + +/// User preference for showing the reset-window pace guide in usage charts. +/// +/// Default off so upgrading users keep the original cleaner charts until +/// they opt in from General settings. +@MainActor +final class PaceGuideStore: ObservableObject { + static let shared = PaceGuideStore() + + private static let key = "MacIsland.paceGuideEnabled" + + @Published var enabled: Bool { + didSet { UserDefaults.standard.set(enabled, forKey: Self.key) } + } + + private init() { + self.enabled = Pref.seededBool(key: Self.key, default: false) + } +} diff --git a/Sources/Views/Charts/BarChart.swift b/Sources/Views/Charts/BarChart.swift index 9200cd1a..abac19dd 100644 --- a/Sources/Views/Charts/BarChart.swift +++ b/Sources/Views/Charts/BarChart.swift @@ -5,6 +5,7 @@ struct BarChart: View { let color: Color let label: String let sub: String + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -16,6 +17,13 @@ struct BarChart: View { .fill(color) .frame(width: geo.size.width * CGFloat(value / 100), height: 4) .animation(.strongEaseOut, value: value) + if let guide { + RoundedRectangle(cornerRadius: 1) + .fill(.white.opacity(0.86)) + .frame(width: 2, height: 11) + .offset(x: geo.size.width * CGFloat(guide / 100)) + .animation(.strongEaseOut, value: guide) + } // Tick marks at quartiles. Subtle (12% white) so they // hint at scale without competing with the fill. ForEach([0.25, 0.5, 0.75], id: \.self) { p in diff --git a/Sources/Views/Charts/NumericChart.swift b/Sources/Views/Charts/NumericChart.swift index 4bf3306d..efb076ca 100644 --- a/Sources/Views/Charts/NumericChart.swift +++ b/Sources/Views/Charts/NumericChart.swift @@ -5,6 +5,7 @@ struct NumericChart: View { let color: Color let label: String let sub: String + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -39,6 +40,13 @@ struct NumericChart: View { .frame(width: geo.size.width * CGFloat(value / 100), height: 3) .shadow(color: color.opacity(0.7), radius: 4) .animation(.strongEaseOut, value: value) + if let guide { + RoundedRectangle(cornerRadius: 1) + .fill(.white.opacity(0.86)) + .frame(width: 2, height: 10) + .offset(x: geo.size.width * CGFloat(guide / 100)) + .animation(.strongEaseOut, value: guide) + } } } .frame(height: 4) diff --git a/Sources/Views/Charts/RingChart.swift b/Sources/Views/Charts/RingChart.swift index fb4baefa..1404801b 100644 --- a/Sources/Views/Charts/RingChart.swift +++ b/Sources/Views/Charts/RingChart.swift @@ -5,6 +5,7 @@ struct RingChart: View { let color: Color let label: String let sub: String + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -20,6 +21,12 @@ struct RingChart: View { // (old → new), which makes the trim feel alive // without ever flashing 0%. .animation(.strongEaseOut, value: value) + if let guide { + RingGuideTick(guide: guide) + .stroke(.white.opacity(0.86), + style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .animation(.strongEaseOut, value: guide) + } } .frame(width: 56, height: 56) @@ -49,3 +56,31 @@ struct RingChart: View { } } } + +private struct RingGuideTick: Shape { + var guide: Double + + var animatableData: Double { + get { guide } + set { guide = newValue } + } + + func path(in rect: CGRect) -> Path { + let radius = min(rect.width, rect.height) / 2 + let center = CGPoint(x: rect.midX, y: rect.midY) + let radians = CGFloat((guide / 100) * 360 - 90) * .pi / 180 + let inner = radius - 8 + let outer = radius + 1 + + var path = Path() + path.move(to: CGPoint( + x: center.x + cos(radians) * inner, + y: center.y + sin(radians) * inner + )) + path.addLine(to: CGPoint( + x: center.x + cos(radians) * outer, + y: center.y + sin(radians) * outer + )) + return path + } +} diff --git a/Sources/Views/Charts/SparkChart.swift b/Sources/Views/Charts/SparkChart.swift index 684ee93e..85ee48d6 100644 --- a/Sources/Views/Charts/SparkChart.swift +++ b/Sources/Views/Charts/SparkChart.swift @@ -6,11 +6,12 @@ struct SparkChart: View { let label: String let sub: String let seed: Int + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 6) { ChartHead(value: value, label: label) - SparkSVG(value: value, color: color, seed: seed) + SparkSVG(value: value, color: color, seed: seed, guide: guide) .frame(height: 50) .animation(.strongEaseOut, value: value) ChartFoot(caption: sub) @@ -22,6 +23,7 @@ private struct SparkSVG: View { let value: Double let color: Color let seed: Int + let guide: Double? /// Synthesize 36 plausible-looking historical points around the current /// value. Real history would need a usage time-series API neither @@ -60,6 +62,7 @@ private struct SparkSVG: View { let w = geo.size.width, h = geo.size.height let pts = generatePoints(width: w, height: h) let baselineY = h - CGFloat(value / 100) * (h - 8) - 4 + let guideY = guide.map { h - CGFloat($0 / 100) * (h - 8) - 4 } ZStack { // Quartile rules at 4% white, barely there. ForEach([0.25, 0.5, 0.75], id: \.self) { p in @@ -69,13 +72,17 @@ private struct SparkSVG: View { } .stroke(.white.opacity(0.04), lineWidth: 1) } - // Dotted threshold at the current value — the line reading - // "this is now" against the synthesized history. + // Dotted threshold: guide when reset data exists; otherwise + // the current value as the old decorative baseline. Path { path in - path.move(to: CGPoint(x: 0, y: baselineY)) - path.addLine(to: CGPoint(x: w, y: baselineY)) + path.move(to: CGPoint(x: 0, y: guideY ?? baselineY)) + path.addLine(to: CGPoint(x: w, y: guideY ?? baselineY)) } - .stroke(color.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [2, 3])) + .stroke( + guideY == nil ? color.opacity(0.25) : .white.opacity(0.38), + style: StrokeStyle(lineWidth: guideY == nil ? 1 : 1.5, dash: [2, 3]) + ) + .animation(.strongEaseOut, value: guide ?? value) // Gradient area fill under the curve. Path { p in diff --git a/Sources/Views/Charts/SteppedChart.swift b/Sources/Views/Charts/SteppedChart.swift index 88db1684..841e906d 100644 --- a/Sources/Views/Charts/SteppedChart.swift +++ b/Sources/Views/Charts/SteppedChart.swift @@ -5,23 +5,37 @@ struct SteppedChart: View { let color: Color let label: String let sub: String + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 8) { ChartHead(value: value, label: label) - HStack(spacing: 2) { - let segments = 30 - let filled = (value / 100) * Double(segments) - ForEach(0.. PaceGuide? { - guard window.error == nil, + private func paceGuidePercent() -> Double? { + guard paceGuide.enabled, + window.error == nil, let resetAt = window.resetAt else { return nil } @@ -186,15 +191,14 @@ struct ChartTile: View { ? 7 * 24 * 60 * 60 : 5 * 60 * 60 let remaining = resetAt.timeIntervalSinceNow - let target = min(1, max(0, 1 - remaining / duration)) - return PaceGuide(actualPercent: window.usedPercent, targetPercent: target) + return min(100, max(0, (1 - remaining / duration) * 100)) } private func accessibilityValue() -> String { - guard let guide = paceGuide() else { return subCaption() } + guard let guide = paceGuidePercent() else { return subCaption() } return L10n.tr("used %d%%, guide %d%%", window.percentInt, - Int((guide.targetPercent * 100).rounded())) + Int(guide.rounded())) } private func subCaption() -> String { @@ -237,48 +241,3 @@ struct ChartTile: View { return "" } } - -private struct PaceGuide { - let actualPercent: Double - let targetPercent: Double -} - -private struct PaceGuideBar: View { - let guide: PaceGuide? - let color: Color - - var body: some View { - Group { - if let guide { - VStack(alignment: .leading, spacing: 2) { - GeometryReader { geo in - let actualX = geo.size.width * CGFloat(guide.actualPercent) - let targetX = geo.size.width * CGFloat(guide.targetPercent) - - ZStack(alignment: .leading) { - Capsule() - .fill(.white.opacity(0.055)) - .frame(height: 3) - Capsule() - .fill(color.opacity(0.72)) - .frame(width: actualX, height: 3) - .animation(.strongEaseOut, value: guide.actualPercent) - Rectangle() - .fill(.white.opacity(0.72)) - .frame(width: 1, height: 8) - .offset(x: targetX) - .animation(.strongEaseOut, value: guide.targetPercent) - } - } - .frame(height: 8) - - Text(L10n.tr("guide %d%%", Int((guide.targetPercent * 100).rounded()))) - .font(Typography.caption) - .foregroundStyle(.white.opacity(0.34)) - .lineLimit(1) - } - .accessibilityHidden(true) - } - } - } -} From a479a74e07e5e4f4c465b5a526e4285e1f8b3aad Mon Sep 17 00:00:00 2001 From: justice-hwan Date: Wed, 3 Jun 2026 02:46:20 +0900 Subject: [PATCH 3/3] fix: keep usage accessibility percentages consistent Use the same rounded window percentage for the accessibility label and value so VoiceOver does not announce conflicting usage numbers. Constraint: CodeRabbit flagged inconsistent rounding between Int(value) and WindowUsage.percentInt. Confidence: high Scope-risk: narrow Tested: ./scripts/verify.sh --- Sources/Views/UsageView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Views/UsageView.swift b/Sources/Views/UsageView.swift index 5903762f..86acd64a 100644 --- a/Sources/Views/UsageView.swift +++ b/Sources/Views/UsageView.swift @@ -177,7 +177,7 @@ struct ChartTile: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(height: Self.tileHeight) .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.tr("%@, %d%%", label, Int(value))) + .accessibilityLabel(L10n.tr("%@, %d%%", label, window.percentInt)) .accessibilityValue(accessibilityValue()) }