diff --git a/app/renderer/components/Punchcard.tsx b/app/renderer/components/Punchcard.tsx index 8e1478ce..f9d9210e 100644 --- a/app/renderer/components/Punchcard.tsx +++ b/app/renderer/components/Punchcard.tsx @@ -96,7 +96,10 @@ export function Punchcard({ timeline }: { timeline: Timeline }) { const track = (event: React.MouseEvent) => { if (!cell.covered || !wrapRef.current) return const r = wrapRef.current.getBoundingClientRect() - setHover({ x: event.clientX - r.left, y: event.clientY - r.top, wd, h }) + // Clamp x so the centered tooltip never crops at the strip's + // edges; the y flip below handles the top rows. + const x = Math.min(Math.max(event.clientX - r.left, 70), r.width - 70) + setHover({ x, y: event.clientY - r.top, wd, h }) } return (
@@ -118,8 +121,12 @@ export function Punchcard({ timeline }: { timeline: Timeline }) { ))} {hover && hovered && (
{WEEKDAYS_FULL[hover.wd]} {String(hover.h).padStart(2, '0')}:00
diff --git a/dash/src/components/Punchcard.tsx b/dash/src/components/Punchcard.tsx index 203d0514..3cc5f68c 100644 --- a/dash/src/components/Punchcard.tsx +++ b/dash/src/components/Punchcard.tsx @@ -123,12 +123,12 @@ export function Punchcard({ timeline }: { timeline: GranularHistory }) { onMouseEnter={(e) => { if (!cell.covered || !wrapRef.current) return const r = wrapRef.current.getBoundingClientRect() - setHover({ x: e.clientX - r.left, y: e.clientY - r.top, wd, h }) + setHover({ x: Math.min(Math.max(e.clientX - r.left, 70), r.width - 70), y: e.clientY - r.top, wd, h }) }} onMouseMove={(e) => { if (!cell.covered || !wrapRef.current) return const r = wrapRef.current.getBoundingClientRect() - setHover({ x: e.clientX - r.left, y: e.clientY - r.top, wd, h }) + setHover({ x: Math.min(Math.max(e.clientX - r.left, 70), r.width - 70), y: e.clientY - r.top, wd, h }) }} >
{WEEKDAYS_FULL[hover.wd]} {pad2(hover.h)}:00 diff --git a/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift b/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift deleted file mode 100644 index 6f31afe5..00000000 --- a/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift +++ /dev/null @@ -1,127 +0,0 @@ -import Foundation - -/// Pure, testable derivation of the menubar Workflow strip from the decoded -/// payload. The view renders exactly these values; all formatting and the -/// coaching-note selection live here so they can be unit-tested without SwiftUI. -/// -/// Every field is nil unless it carries real signal: a stat is never shown as a -/// zero placeholder. `isEmpty` is true when none of the three stats fire, and -/// the section renders nothing in that case. -struct WorkflowStripModel: Equatable { - /// Correction rate as a percent with the raw count, e.g. "3% (5)". - let corrections: String? - /// Median time to the first edit, e.g. "6m". - let firstEdit: String? - /// Most reworked file, basename only, e.g. "sdk.py". - let reworkedName: String? - /// Distinct-session count for the most reworked file, e.g. 15. - let reworkedSessions: Int? - /// One coaching note, or nil. Mirrors the first-firing note in - /// src/workflow-insights.ts buildCoachingNotes over the payload's signals. - let note: String? - - /// Nothing worth showing -> the section hides itself. - var isEmpty: Bool { - corrections == nil && firstEdit == nil && reworkedName == nil - } - - /// The reworked stat as one string, e.g. "sdk.py ×15" (used for the exact - /// rendered value and by tests). - var reworked: String? { - guard let reworkedName, let reworkedSessions else { return nil } - return "\(reworkedName) ×\(reworkedSessions)" - } -} - -extension WorkflowStripModel { - // Coaching-note thresholds, matching src/workflow-insights.ts exactly. - static let correctionHighRate = 0.15 - static let correctionMinCount = 3 - static let churnMinSessions = 3 - static let ttfeSlowMs: Double = 5 * 60 * 1000 - - init(workflow: WorkflowBlock?, topReworkedFiles: [ReworkedFileEntry]) { - let topFile = topReworkedFiles.first - - // Corrections: only when the displayed rate is a real, non-zero percent. - // The rate is the headline; a count of corrections that rounds to 0% (a - // handful over thousands of turns) carries no signal, so the whole stat - // hides rather than reading "0% (6)". - if let rate = workflow?.correctionRate, let count = workflow?.corrections, - count > 0, Int((rate * 100).rounded()) >= 1 { - corrections = "\(Self.percent(rate)) (\(count))" - } else { - corrections = nil - } - - // First edit: only a real, positive median. - if let ms = workflow?.medianTimeToFirstEditMs, ms > 0 { - firstEdit = Self.formatDuration(ms: ms) - } else { - firstEdit = nil - } - - // Top reworked file: basename ×sessions. - if let file = topFile, file.sessions > 0 { - reworkedName = Self.basename(file.path) - reworkedSessions = file.sessions - } else { - reworkedName = nil - reworkedSessions = nil - } - - note = Self.coachingNote( - corrections: workflow?.corrections ?? 0, - correctionRate: workflow?.correctionRate, - topFile: topFile, - medianTimeToFirstEditMs: workflow?.medianTimeToFirstEditMs - ) - } - - /// Picks the single coaching note: the first threshold that fires, in the - /// same order as buildCoachingNotes (the worst-one-shot note is omitted - /// because that signal is not in the menubar payload). Copy is byte-for-byte - /// the CLI's, no em-dashes. - static func coachingNote(corrections: Int, - correctionRate: Double?, - topFile: ReworkedFileEntry?, - medianTimeToFirstEditMs: Double?) -> String? { - if let rate = correctionRate, rate >= correctionHighRate, corrections >= correctionMinCount { - return "You corrected the assistant on \(percent(rate)) of prompts (\(corrections) times). State the requirements in the first message to cut the back and forth." - } - if let file = topFile, file.sessions >= churnMinSessions { - return "\(basename(file.path)) was reworked across \(file.sessions) sessions (\(file.edits) edits). A focused pass on it may cost less than the repeated churn." - } - if let ms = medianTimeToFirstEditMs, ms >= ttfeSlowMs { - return "Median time to first edit is \(formatDurationShort(ms: ms)). Point the assistant at the target file to cut the exploration before it starts editing." - } - return nil - } - - /// "First edit" stat duration: <60s -> "Ns", <60m -> "Nm", else "Nh Nm". - static func formatDuration(ms: Double) -> String { - if ms < 60_000 { return "\(Int((ms / 1000).rounded()))s" } - let totalMinutes = Int((ms / 60_000).rounded()) - if totalMinutes < 60 { return "\(totalMinutes)m" } - return "\(totalMinutes / 60)h \(totalMinutes % 60)m" - } - - /// Mirrors formatDurationShort in src/workflow-insights.ts: whole seconds - /// under a minute, whole minutes above. Used only inside the coaching note so - /// the copy matches the CLI exactly. - static func formatDurationShort(ms: Double) -> String { - if ms >= 60_000 { return "\(Int((ms / 60_000).rounded()))m" } - return "\(Int((ms / 1000).rounded()))s" - } - - /// Rounded percent from a 0-1 rate, matching JS `Math.round(rate * 100)`. - static func percent(_ rate: Double) -> String { - "\(Int((rate * 100).rounded()))%" - } - - /// Last path component. The payload is already basename-only, but an older - /// CLI could emit a fuller (forward-slash) path. - static func basename(_ path: String) -> String { - path.split(separator: "/").last.map(String.init) ?? path - } -} diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 51f0426d..0aab756b 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -36,7 +36,6 @@ struct MenuBarContent: View { ActivitySection() Divider().opacity(0.5) ModelsSection() - WorkflowSection() PullRequestsSection() Divider().opacity(0.5) ToolingSection() diff --git a/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift b/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift deleted file mode 100644 index 6d7593f5..00000000 --- a/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SwiftUI - -/// Compact workflow-intelligence strip: one row of up to three stats plus one -/// coaching note. Reads whatever payload the store currently holds, so it -/// follows the selected agent tab automatically. Renders nothing when the -/// payload carries no workflow signal (older CLIs, or a period with nothing -/// measurable), so it never shows zero placeholders. -struct WorkflowSection: View { - @Environment(AppStore.self) private var store - - var body: some View { - let model = WorkflowStripModel( - workflow: store.payload.current.workflow, - topReworkedFiles: store.payload.current.topReworkedFiles - ) - if !model.isEmpty { - // Own the leading divider so the section slots into the popover's - // divider rhythm and leaves no doubled line when it's hidden. - VStack(spacing: 0) { - Divider().opacity(0.5) - VStack(alignment: .leading, spacing: 8) { - SectionCaption(text: "Workflow") - WorkflowStatRow(model: model) - if let note = model.note { - Text(note) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 11) - } - } - } -} - -private enum WorkflowStat: Identifiable { - case labeled(id: String, label: String, value: String) - case reworked(name: String, sessions: Int) - - var id: String { - switch self { - case .labeled(let id, _, _): return id - case .reworked: return "reworked" - } - } -} - -private struct WorkflowStatRow: View { - let model: WorkflowStripModel - - private var stats: [WorkflowStat] { - var s: [WorkflowStat] = [] - if let corrections = model.corrections { - s.append(.labeled(id: "corrections", label: "Corrections", value: corrections)) - } - if let firstEdit = model.firstEdit { - s.append(.labeled(id: "firstEdit", label: "First edit", value: firstEdit)) - } - if let name = model.reworkedName, let sessions = model.reworkedSessions { - s.append(.reworked(name: name, sessions: sessions)) - } - return s - } - - var body: some View { - HStack(spacing: 7) { - ForEach(Array(stats.enumerated()), id: \.element.id) { index, stat in - if index > 0 { - Text("·") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - } - WorkflowStatCell(stat: stat) - } - Spacer(minLength: 0) - } - } -} - -private struct WorkflowStatCell: View { - let stat: WorkflowStat - - var body: some View { - HStack(spacing: 4) { - switch stat { - case .labeled(_, let label, let value): - Text(label) - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - Text(value) - .font(.codeMono(size: 12, weight: .medium)) - .foregroundStyle(.primary) - .tracking(-0.2) - case .reworked(let name, let sessions): - Text(name) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.middle) - Text("×\(sessions)") - .font(.codeMono(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .tracking(-0.2) - } - } - } -} diff --git a/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift b/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift deleted file mode 100644 index e42968d2..00000000 --- a/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift +++ /dev/null @@ -1,235 +0,0 @@ -import Foundation -import Testing -@testable import CodeBurnMenubar - -@Suite("WorkflowStrip -- duration formatter") -struct WorkflowStripDurationTests { - @Test("seconds under a minute") - func seconds() { - #expect(WorkflowStripModel.formatDuration(ms: 8_000) == "8s") - #expect(WorkflowStripModel.formatDuration(ms: 45_000) == "45s") - // Rounds to nearest second (away from zero on .5). - #expect(WorkflowStripModel.formatDuration(ms: 1_500) == "2s") - } - - @Test("whole minutes below an hour") - func minutes() { - #expect(WorkflowStripModel.formatDuration(ms: 60_000) == "1m") - #expect(WorkflowStripModel.formatDuration(ms: 360_000) == "6m") - // 1m30s rounds up to 2m. - #expect(WorkflowStripModel.formatDuration(ms: 90_000) == "2m") - } - - @Test("hours and minutes at and above an hour") - func hours() { - #expect(WorkflowStripModel.formatDuration(ms: 3_600_000) == "1h 0m") - #expect(WorkflowStripModel.formatDuration(ms: 3_900_000) == "1h 5m") - #expect(WorkflowStripModel.formatDuration(ms: 7_500_000) == "2h 5m") - } - - @Test("coaching-note short formatter matches the CLI (seconds/minutes only)") - func shortFormatter() { - #expect(WorkflowStripModel.formatDurationShort(ms: 45_000) == "45s") - #expect(WorkflowStripModel.formatDurationShort(ms: 300_000) == "5m") - // Above an hour the note still reads minutes, unlike the stat (which is "1h 5m"). - #expect(WorkflowStripModel.formatDurationShort(ms: 3_900_000) == "65m") - } -} - -@Suite("WorkflowStrip -- coaching note selection") -struct WorkflowStripNoteTests { - private func file(_ path: String, sessions: Int, edits: Int) -> ReworkedFileEntry { - ReworkedFileEntry(path: path, sessions: sessions, edits: edits) - } - - @Test("corrections note fires at threshold and wins over the others") - func correctionsWins() { - let note = WorkflowStripModel.coachingNote( - corrections: 5, - correctionRate: 0.2, - topFile: file("sdk.py", sessions: 15, edits: 42), - medianTimeToFirstEditMs: 600_000 - ) - #expect(note == "You corrected the assistant on 20% of prompts (5 times). State the requirements in the first message to cut the back and forth.") - } - - @Test("corrections note gated by rate and count") - func correctionsGating() { - // Rate below 0.15 -> falls through to churn. - #expect(WorkflowStripModel.coachingNote(corrections: 9, correctionRate: 0.1, topFile: file("a.py", sessions: 4, edits: 8), medianTimeToFirstEditMs: nil) - == "a.py was reworked across 4 sessions (8 edits). A focused pass on it may cost less than the repeated churn.") - // Count below 3 -> falls through to churn. - #expect(WorkflowStripModel.coachingNote(corrections: 2, correctionRate: 0.9, topFile: file("a.py", sessions: 4, edits: 8), medianTimeToFirstEditMs: nil) - == "a.py was reworked across 4 sessions (8 edits). A focused pass on it may cost less than the repeated churn.") - } - - @Test("churn note fires and uses the basename") - func churn() { - let note = WorkflowStripModel.coachingNote( - corrections: 0, - correctionRate: nil, - topFile: file("src/lib/sdk.py", sessions: 15, edits: 42), - medianTimeToFirstEditMs: nil - ) - #expect(note == "sdk.py was reworked across 15 sessions (42 edits). A focused pass on it may cost less than the repeated churn.") - } - - @Test("ttfe note fires at 5 minutes when nothing stronger does") - func ttfe() { - let note = WorkflowStripModel.coachingNote( - corrections: 0, - correctionRate: nil, - topFile: file("a.py", sessions: 2, edits: 3), - medianTimeToFirstEditMs: 300_000 - ) - #expect(note == "Median time to first edit is 5m. Point the assistant at the target file to cut the exploration before it starts editing.") - } - - @Test("no note when nothing crosses a threshold") - func none() { - #expect(WorkflowStripModel.coachingNote(corrections: 1, correctionRate: 0.05, topFile: file("a.py", sessions: 1, edits: 2), medianTimeToFirstEditMs: 120_000) == nil) - #expect(WorkflowStripModel.coachingNote(corrections: 0, correctionRate: nil, topFile: nil, medianTimeToFirstEditMs: nil) == nil) - } -} - -@Suite("WorkflowStrip -- model derivation") -struct WorkflowStripModelTests { - @Test("full payload renders all three stats and a note") - func full() { - let model = WorkflowStripModel( - workflow: WorkflowBlock(corrections: 5, correctionRate: 0.03, medianTimeToFirstEditMs: 360_000), - topReworkedFiles: [ReworkedFileEntry(path: "sdk.py", sessions: 15, edits: 42)] - ) - #expect(model.corrections == "3% (5)") - #expect(model.firstEdit == "6m") - #expect(model.reworked == "sdk.py ×15") - #expect(model.isEmpty == false) - // rate 0.03 < 0.15 so corrections note doesn't fire; churn does. - #expect(model.note == "sdk.py was reworked across 15 sessions (42 edits). A focused pass on it may cost less than the repeated churn.") - } - - @Test("basename is applied to the reworked stat") - func reworkedBasename() { - let model = WorkflowStripModel( - workflow: nil, - topReworkedFiles: [ReworkedFileEntry(path: "app/src/main.ts", sessions: 4, edits: 9)] - ) - #expect(model.reworkedName == "main.ts") - #expect(model.reworked == "main.ts ×4") - } - - @Test("nil workflow and empty files -> hidden") - func emptyHidden() { - let model = WorkflowStripModel(workflow: nil, topReworkedFiles: []) - #expect(model.isEmpty) - #expect(model.corrections == nil) - #expect(model.firstEdit == nil) - #expect(model.reworkedName == nil) - #expect(model.note == nil) - } - - @Test("all-empty workflow (zeros/nulls) -> hidden, no zero placeholders") - func allEmptyHidden() { - let model = WorkflowStripModel( - workflow: WorkflowBlock(corrections: 0, correctionRate: 0.0, medianTimeToFirstEditMs: nil), - topReworkedFiles: [] - ) - #expect(model.isEmpty) - #expect(model.corrections == nil) // 0 corrections is never "0% (0)" - #expect(model.firstEdit == nil) - } - - @Test("zero median and zero-session file are treated as absent") - func degenerateValues() { - let model = WorkflowStripModel( - workflow: WorkflowBlock(corrections: 0, correctionRate: nil, medianTimeToFirstEditMs: 0), - topReworkedFiles: [ReworkedFileEntry(path: "a.py", sessions: 0, edits: 0)] - ) - #expect(model.firstEdit == nil) - #expect(model.reworkedName == nil) - #expect(model.isEmpty) - } - - @Test("a correction rate that rounds to 0% hides the corrections stat") - func subOnePercentCorrections() { - // Mirrors real month data: 6 corrections over thousands of turns -> 0.17%. - let model = WorkflowStripModel( - workflow: WorkflowBlock(corrections: 6, correctionRate: 0.001692524682651622, medianTimeToFirstEditMs: 384_425), - topReworkedFiles: [ReworkedFileEntry(path: "sdk.py", sessions: 9, edits: 54)] - ) - #expect(model.corrections == nil) - #expect(model.firstEdit == "6m") - #expect(model.reworked == "sdk.py ×9") - #expect(model.note == "sdk.py was reworked across 9 sessions (54 edits). A focused pass on it may cost less than the repeated churn.") - #expect(model.isEmpty == false) - } - - @Test("only a reworked file present renders one stat and the churn note") - func onlyReworked() { - let model = WorkflowStripModel( - workflow: nil, - topReworkedFiles: [ReworkedFileEntry(path: "a.py", sessions: 4, edits: 10)] - ) - #expect(model.corrections == nil) - #expect(model.firstEdit == nil) - #expect(model.reworked == "a.py ×4") - #expect(model.isEmpty == false) - #expect(model.note == "a.py was reworked across 4 sessions (10 edits). A focused pass on it may cost less than the repeated churn.") - } -} - -@Suite("WorkflowStrip -- payload decoding") -struct WorkflowStripDecodeTests { - private func decodeCurrent(_ json: String) throws -> CurrentBlock { - try JSONDecoder().decode(CurrentBlock.self, from: Data(json.utf8)) - } - - @Test("decodes workflow and topReworkedFiles when present") - func present() throws { - let current = try decodeCurrent(""" - { - "label": "Month", "cost": 1, "calls": 2, "sessions": 3, - "inputTokens": 4, "outputTokens": 5, - "workflow": { "corrections": 5, "correctionRate": 0.03, "medianTimeToFirstEditMs": 360000 }, - "topReworkedFiles": [ { "path": "sdk.py", "sessions": 15, "edits": 42 } ] - } - """) - #expect(current.workflow?.corrections == 5) - #expect(current.workflow?.correctionRate == 0.03) - #expect(current.workflow?.medianTimeToFirstEditMs == 360_000) - #expect(current.topReworkedFiles.first?.path == "sdk.py") - #expect(current.topReworkedFiles.first?.sessions == 15) - #expect(current.topReworkedFiles.first?.edits == 42) - } - - @Test("decodes null workflow rates to nil") - func nulls() throws { - let current = try decodeCurrent(""" - { - "label": "Month", "cost": 1, "calls": 2, "sessions": 3, - "inputTokens": 4, "outputTokens": 5, - "workflow": { "corrections": 0, "correctionRate": null, "medianTimeToFirstEditMs": null }, - "topReworkedFiles": [] - } - """) - #expect(current.workflow?.corrections == 0) - #expect(current.workflow?.correctionRate == nil) - #expect(current.workflow?.medianTimeToFirstEditMs == nil) - #expect(current.topReworkedFiles.isEmpty) - } - - @Test("older payloads without the keys decode fine") - func backwardCompatible() throws { - let current = try decodeCurrent(""" - { - "label": "Month", "cost": 1, "calls": 2, "sessions": 3, - "inputTokens": 4, "outputTokens": 5 - } - """) - #expect(current.workflow == nil) - #expect(current.topReworkedFiles.isEmpty) - // And an older payload yields a hidden strip. - let model = WorkflowStripModel(workflow: current.workflow, topReworkedFiles: current.topReworkedFiles) - #expect(model.isEmpty) - } -}