{
  "format" : 1,
  "manifest" : {
    "accent" : "red",
    "capabilities" : [

    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Pomodoro",
    "id" : "io.navbytes.stackbar.plugins.pomodoro",
    "minimumStackbar" : "0.17.0",
    "name" : "Pomodoro",
    "summary" : "A focus timer with presets, a progress ring, tags and today's sessions.",
    "surfaces" : [
      "menuBar",
      "panel",
      "settings",
      "widget"
    ],
    "symbol" : "timer",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 1
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.4.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "sUDwoSX078zd/0b60qScppO4Be5jhk4eaT1SdcURmuFwQv4Ckk8nwZyuQEeM69Ii+GrlL1PNVnEiHgAGrfqpBA==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// A focus timer in the Stackbar look: Focus and Break modes, presets, a\n/// big countdown inside a progress ring, a tag for what you are working on,\n/// and today's sessions. Stackbar counts the timer and animates the ring, so\n/// the plugin only renders when something changes. Commands start, pause and\n/// skip from the palette, a hotkey or a Shortcut, and a stray tap never\n/// throws away a session already under way. Needs nothing.\n@main\nfinal class Pomodoro: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.pomodoro\",\n        name: \"Pomodoro\",\n        version: \"1.4.0\",\n        summary: \"A focus timer with presets, a progress ring, tags and today's sessions.\",\n        surfaces: [.menuBar, .panel, .settings, .widget],\n        triggers: [.launch, .every(.seconds(1)), .wake],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Pomodoro\",\n        accent: .red,\n        symbol: \"timer\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<Pomodoro>] = [\n        PreviewScene(\"hero\", \"Twenty-five focused minutes, counting down\") { plugin in\n            plugin.session = Session(mode: .focus, minutes: 25, tag: \"Deep Work\", started: Date().addingTimeInterval(-498), remaining: 25 * 60)\n            plugin.days = [plugin.dayKey: Day(sessions: 3, minutes: 75)]\n        },\n        PreviewScene(\"break\", \"A real break between sessions\") { plugin in\n            plugin.mode = .rest\n            plugin.session = Session(mode: .rest, minutes: 5, tag: \"Deep Work\", started: Date().addingTimeInterval(-70), remaining: 5 * 60)\n            plugin.days = [plugin.dayKey: Day(sessions: 4, minutes: 100)]\n        },\n        PreviewScene(\"widget\", \"Your focus timer on the desktop\", surface: .widget) { plugin in\n            plugin.session = Session(mode: .focus, minutes: 25, tag: \"Deep Work\", started: Date().addingTimeInterval(-498), remaining: 25 * 60)\n            plugin.days = [plugin.dayKey: Day(sessions: 3, minutes: 75)]\n        },\n    ]\n\n    enum Mode: String, Codable, CaseIterable {\n        case focus = \"Focus\", rest = \"Break\"\n\n        var symbol: String { self == .focus ? \"flame.fill\" : \"cup.and.saucer.fill\" }\n    }\n\n    /// A running session, or a paused one with time left.\n    struct Session: Codable, Equatable {\n        var mode: Mode\n        var minutes: Int\n        var tag: String\n        var started: Date?\n        var remaining: TimeInterval\n\n        var end: Date? { started.map { $0.addingTimeInterval(remaining) } }\n\n        /// The whole session as if it had never paused: the same on every\n        /// render, so Stackbar counts it and nothing is resent each second.\n        var range: TimeRange? {\n            guard let started, let end else { return nil }\n            return TimeRange(start: started.addingTimeInterval(remaining - Double(minutes * 60)), end: end)\n        }\n    }\n\n    /// A tap that would end a session already under way, held until the\n    /// user confirms it.\n    enum Pending: Equatable {\n        case mode(Mode), minutes(Int), skip, reset\n\n        var verb: String {\n            switch self {\n            case .mode(let mode): return \"Switch to \\(mode.rawValue)\"\n            case .minutes(let minutes): return \"Start \\(minutes) min\"\n            case .skip: return \"Skip\"\n            case .reset: return \"Reset\"\n            }\n        }\n    }\n\n    struct Day: Codable, Equatable {\n        var sessions = 0\n        var minutes = 0\n    }\n\n    /// Focus in the tomato red, breaks in a calm teal, from the shared\n    /// design system like every other Stackbar plugin.\n    var theme: Theme { mode == .focus ? .red : .teal }\n\n    @Stored(\"mode\") var mode = Mode.focus\n    @Stored(\"focusMinutes\") var focusMinutes = 25\n    @Stored(\"breakMinutes\") var breakMinutes = 5\n    @Stored(\"session\") var session: Session? = nil\n    @Stored(\"tags\") var tags = [\"Deep Work\", \"Email\", \"Meetings\", \"Learning\"]\n    @Stored(\"tag\") var tag = \"Deep Work\"\n    @Stored(\"autoStart\") var autoStart = false\n    @Stored(\"sound\") var sound = true\n    @Stored(\"chime\") var chime = SystemSound.glass.rawValue\n    @Stored(\"days\") var days: [String: Day] = [:]\n\n    var now = Date()\n    var pending: Pending?\n\n    /// How far into a session a tap has to be before it asks first.\n    static let protectedAfter: TimeInterval = 60\n\n    func refresh() async throws {\n        now = Date()\n        if let session, let end = session.end, now >= end { finish(session) }\n    }\n\n    // MARK: - Surfaces\n\n    var menuBar: ViewContent {\n        if let session, let range = session.range {\n            HStack(spacing: 4) {\n                Image(systemName: session.mode.symbol)\n                TimerText(range)\n            }\n        } else {\n            if let session {\n                // Paused: the frozen time, dimmed, behind a pause sign.\n                Label(clock(session.remaining), symbol: \"pause.fill\").role(.secondary)\n            } else {\n                Label(\"\", symbol: \"timer\").role(.secondary)\n            }\n        }\n    }\n\n    var panel: ViewContent {\n        PanelHeader(headline, subtitle: \"\\(today.sessions) sessions · \\(minutesText(today.minutes)) focused today\", symbol: mode.symbol, theme: theme) {\n            IconButton(\"arrow.counterclockwise\", label: \"Reset\", theme: theme, key: \"reset\") { self.guarded(.reset) }\n        }\n        if let pending {\n            Banner(warning(for: pending), symbol: \"hand.raised.fill\", theme: theme) {\n                ActionButton(\"Keep going\", theme: theme, key: \"keep\") { self.pending = nil }\n                ActionButton(pending.verb, theme: theme, prominent: true, key: \"confirm\") { self.apply(pending) }\n            }\n        }\n        Picker(\"Mode\", options: Mode.allCases.map(\\.rawValue), selection: mode.rawValue, style: .segmented, key: \"mode\") { choice in\n            self.guarded(.mode(Mode(rawValue: choice) ?? .focus))\n        }\n        HeroCard(theme: theme, look: .glow, alignment: .center, spacing: Space.m) {\n            dial(size: 172, lineWidth: 12, timeSize: 40)\n            HStack(spacing: Space.xs) {\n                ForEach(presetMinutes) { minutes in\n                    Chip(\"\\(minutes) min\", theme: theme, selected: minutes == currentMinutes, key: \"preset-\\(minutes)\") { self.guarded(.minutes(minutes)) }\n                }\n            }\n            controls\n        }\n        HStack(spacing: Space.s) {\n            StatTile(\"Sessions\", value: \"\\(today.sessions)\", symbol: \"checkmark.circle.fill\", theme: theme)\n            StatTile(\"Focused\", value: minutesText(today.minutes), symbol: \"clock.fill\", theme: theme)\n        }\n    }\n\n    var widget: ViewContent {\n        HStack(spacing: Space.l) {\n            dial(size: 104, lineWidth: 8, timeSize: 22, showTag: false)\n            VStack(spacing: Space.xxs) {\n                Text(mode.rawValue.uppercased()).typography(.overline).foreground(theme.textPaint)\n                Text(currentTag).typography(.title)\n                Text(\"\\(today.sessions) sessions · \\(minutesText(today.minutes)) today\").role(.secondary).typography(.caption)\n            }\n            .frame(fillWidth: true, alignment: .leading)\n        }\n        .frame(fillWidth: true, fillHeight: true)\n    }\n\n    var settings: ViewContent {\n        Section(\"Durations\") {\n            Slider(\"Focus minutes\", value: Double(focusMinutes), in: 5...120, step: 5) { self.focusMinutes = Int($0) }\n            Slider(\"Break minutes\", value: Double(breakMinutes), in: 1...30, step: 1) { self.breakMinutes = Int($0) }\n            Toggle(\"Start the next session on its own\", isOn: autoStart) { self.autoStart = $0 }\n        }\n        Section(\"Sound\") {\n            Toggle(\"Play a sound when a session ends\", isOn: sound) { self.sound = $0 }\n            Picker(\"Sound\", options: SystemSound.allCases.map(\\.rawValue), selection: chime) { self.chime = $0 }\n        }\n        Section(\"Tags\") {\n            ForEach(tags) { name in\n                Row(name, symbol: \"tag\", key: \"tag-\\(name)\").action(\"Remove\") { self.tags.removeAll { $0 == name } }\n            }\n            TextField(\"Add a tag\", text: \"\") { submitted in\n                let name = submitted.trimmingCharacters(in: .whitespaces)\n                if !name.isEmpty, !self.tags.contains(name) { self.tags.append(name) }\n            }\n        }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"status\", description: \"The timer's mode, time left and today's focus\") { _ in\n            let today = self.today\n            return \"\\(self.statusLine). Today: \\(today.sessions) sessions, \\(today.minutes) minutes focused.\"\n        }\n    }\n\n    var commands: [Command] {\n        Command(\"toggle\", title: \"Start or Pause Pomodoro\", symbol: \"playpause.fill\", keywords: [\"pomodoro\", \"timer\", \"focus\", \"pause\", \"resume\", \"tomato\"]) { _ in\n            self.toggle()\n            return .message(self.statusLine)\n        }\n        Command(\"start-focus\", title: \"Start a Focus Session\", subtitle: \"Optionally a length in minutes and a tag\", symbol: Mode.focus.symbol, keywords: [\"pomodoro\", \"timer\", \"focus\", \"work\", \"tomato\"]) { invocation in\n            self.command(.focus, minutes: invocation[\"minutes\"], tag: invocation[\"tag\"])\n        }\n        .argument(\"minutes\", placeholder: \"\\(focusMinutes)\", required: false)\n        .argument(\"tag\", placeholder: tag, required: false, options: tags)\n        Command(\"start-break\", title: \"Take a Break\", subtitle: \"Optionally a length in minutes\", symbol: Mode.rest.symbol, keywords: [\"pomodoro\", \"timer\", \"break\", \"rest\", \"tomato\"]) { invocation in\n            self.command(.rest, minutes: invocation[\"minutes\"], tag: nil)\n        }\n        .argument(\"minutes\", placeholder: \"\\(breakMinutes)\", required: false)\n        Command(\"skip\", title: \"Skip to the Next Pomodoro Session\", symbol: \"forward.fill\", keywords: [\"pomodoro\", \"timer\", \"skip\", \"next\"]) { _ in\n            self.skip()\n            return .message(\"Up next: \\(self.mode.rawValue), \\(self.currentMinutes) min\")\n        }\n        Command(\"reset\", title: \"Reset Pomodoro\", symbol: \"arrow.counterclockwise\", keywords: [\"pomodoro\", \"timer\", \"reset\", \"stop\", \"cancel\"]) { _ in\n            self.reset()\n            return .message(\"Pomodoro reset\")\n        }\n    }\n\n    // MARK: - Panel parts\n\n    private var headline: String {\n        if isRunning { return mode == .focus ? \"Focusing\" : \"On a break\" }\n        return session != nil ? \"Paused\" : (mode == .focus ? \"Ready to focus\" : \"Ready for a break\")\n    }\n\n    /// The ring with the countdown in the middle and, under it, the tag,\n    /// which changes on a click.\n    @ViewBuilder private func dial(size: Double, lineWidth: Double, timeSize: Double, showTag: Bool = true) -> ViewContent {\n        ZStack {\n            // A neutral track, so the ring still shows before it fills.\n            Circle().stroke(.hex(\"#00000017\", dark: \"#FFFFFF1F\"), lineWidth: lineWidth)\n            progress(lineWidth: lineWidth)\n            VStack(alignment: .center, spacing: Space.xxs) {\n                if isRunning {\n                    Image(systemName: mode.symbol).font(size: timeSize * 0.4, weight: .semibold).foreground(theme.textPaint).symbolEffect(.breathe)\n                } else {\n                    Image(systemName: mode.symbol).font(size: timeSize * 0.4, weight: .semibold).foreground(theme.textPaint)\n                }\n                timeText.typography(Typography(size: timeSize, weight: .bold, design: .rounded, monospacedDigit: true))\n                if showTag {\n                    Chip(currentTag, symbol: \"tag.fill\", theme: theme, key: \"tag\") { self.nextTag() }\n                        .accessibilityLabel(\"Change tag: \\(currentTag)\")\n                }\n            }\n        }\n        .frame(width: size, height: size)\n    }\n\n    @ViewBuilder private var controls: ViewContent {\n        HStack(alignment: .center, spacing: Space.m) {\n            IconButton(autoStart ? \"repeat\" : \"repeat.1\", label: autoStart ? \"Auto-start on\" : \"Auto-start off\", theme: theme, size: 34, key: \"auto\") { self.autoStart.toggle() }\n            IconButton(\"forward.fill\", label: \"Skip\", theme: theme, size: 34, key: \"skip\") { self.guarded(.skip) }\n            IconButton(isRunning ? \"pause.fill\" : \"play.fill\", label: isRunning ? \"Pause\" : \"Start\", theme: theme, prominence: .filled, size: 52, key: \"start\") { self.toggle() }\n            IconButton(sound ? \"speaker.wave.2.fill\" : \"speaker.slash.fill\", label: sound ? \"Sound on\" : \"Sound off\", theme: theme, size: 34, key: \"sound\") { self.sound.toggle() }\n        }\n    }\n\n    @ViewBuilder private var timeText: ViewContent {\n        if let range = session?.range {\n            TimerText(range)\n        } else {\n            Text(clock(session?.remaining ?? Double(currentMinutes * 60)))\n        }\n    }\n\n    @ViewBuilder private func progress(lineWidth: Double) -> ViewContent {\n        if let range = session?.range {\n            ProgressBar(timing: range, style: .ring(lineWidth: lineWidth)).foreground(theme.sweep)\n        } else {\n            let total = Double(currentMinutes * 60)\n            ProgressBar(value: total - (session?.remaining ?? total), total: total, style: .ring(lineWidth: lineWidth)).foreground(theme.sweep)\n        }\n    }\n\n    // MARK: - State\n\n    private var isRunning: Bool { session?.started != nil }\n    private var currentMinutes: Int { session?.minutes ?? (mode == .focus ? focusMinutes : breakMinutes) }\n    private var currentTag: String { session?.tag ?? tag }\n    private var presetMinutes: [Int] { mode == .focus ? [25, 50, 90] : [5, 10, 15] }\n\n    private var dayKey: String {\n        let parts = Calendar.current.dateComponents([.year, .month, .day], from: now)\n        return String(format: \"%04d-%02d-%02d\", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)\n    }\n\n    private var today: Day { days[dayKey] ?? Day() }\n\n    private func left(_ session: Session) -> TimeInterval {\n        session.end.map { max(0, $0.timeIntervalSince(now)) } ?? session.remaining\n    }\n\n    /// One line on where the timer stands, for commands and the agent tool.\n    private var statusLine: String {\n        now = Date()\n        guard let session else { return \"Idle. Next: \\(mode.rawValue), \\(currentMinutes) min\" }\n        let state = session.started == nil ? \"Paused\" : (session.mode == .focus ? \"Focusing\" : \"On a break\")\n        return \"\\(state)\\(session.mode == .focus ? \" on \\(session.tag)\" : \"\"), \\(clock(left(session))) left\"\n    }\n\n    /// Seconds of the current session already behind the user.\n    private var progress: TimeInterval {\n        guard let session else { return 0 }\n        return Double(session.minutes * 60) - left(session)\n    }\n\n    /// Applies a tap now, or, when it would end a session more than a\n    /// minute in, asks first with a banner.\n    private func guarded(_ change: Pending) {\n        now = Date()\n        if session != nil, progress >= Self.protectedAfter {\n            pending = change\n        } else {\n            apply(change)\n        }\n    }\n\n    private func apply(_ change: Pending) {\n        pending = nil\n        switch change {\n        case .mode(let mode): select(mode)\n        case .minutes(let minutes): setMinutes(minutes)\n        case .skip: skip()\n        case .reset: reset()\n        }\n    }\n\n    private func warning(for change: Pending) -> String {\n        let done = \"\\(Int(progress / 60)) min in\"\n        switch change {\n        case .skip: return \"Skipping ends this session without counting it (\\(done)).\"\n        case .reset: return \"Resetting throws away this session (\\(done)).\"\n        case .mode, .minutes: return \"This ends the session you are in (\\(done)).\"\n        }\n    }\n\n    /// What a start command does: resumes or reports a session of the same\n    /// kind, otherwise starts a fresh one with any minutes and tag given.\n    private func command(_ kind: Mode, minutes text: String?, tag name: String?) -> CommandResult {\n        let text = text?.trimmingCharacters(in: .whitespaces).lowercased().replacingOccurrences(of: \"min\", with: \"\").trimmingCharacters(in: .whitespaces) ?? \"\"\n        let name = name?.trimmingCharacters(in: .whitespaces) ?? \"\"\n        var minutes: Int?\n        if !text.isEmpty {\n            guard let value = Int(text), (1...180).contains(value) else { return .failure(\"Give a length between 1 and 180 minutes\") }\n            minutes = value\n        }\n        if let session, session.mode == kind, minutes == nil, name.isEmpty {\n            if session.started == nil { toggle() }\n            return .message(statusLine)\n        }\n        now = Date()\n        pending = nil\n        mode = kind\n        if !name.isEmpty { tag = name }\n        let length = minutes ?? (kind == .focus ? focusMinutes : breakMinutes)\n        session = Session(mode: kind, minutes: length, tag: tag, started: now, remaining: Double(length * 60))\n        return .message(statusLine)\n    }\n\n    private func toggle() {\n        now = Date()\n        if var session {\n            if session.started != nil {\n                session.remaining = left(session)\n                session.started = nil\n            } else {\n                session.started = now\n            }\n            self.session = session\n        } else {\n            session = Session(mode: mode, minutes: currentMinutes, tag: tag, started: now, remaining: Double(currentMinutes * 60))\n        }\n    }\n\n    private func select(_ option: Mode) {\n        guard option != mode else { return }\n        mode = option\n        session = nil\n    }\n\n    private func setMinutes(_ minutes: Int) {\n        if mode == .focus { focusMinutes = minutes } else { breakMinutes = minutes }\n        session = nil\n    }\n\n    private func nextTag() {\n        guard !tags.isEmpty else { return }\n        let index = tags.firstIndex(of: currentTag).map { ($0 + 1) % tags.count } ?? 0\n        tag = tags[index]\n        session?.tag = tag\n    }\n\n    private func skip() {\n        if let session { finish(session, counted: false) } else { select(mode == .focus ? .rest : .focus) }\n    }\n\n    private func reset() {\n        pending = nil\n        session = nil\n    }\n\n    /// Ends a session: counts a finished focus session, chimes, and moves to\n    /// the other mode, starting it when auto-start is on.\n    private func finish(_ ended: Session, counted: Bool = true) {\n        if counted && ended.mode == .focus {\n            var day = today\n            day.sessions += 1\n            day.minutes += ended.minutes\n            days[dayKey] = day\n            for old in days.keys.sorted().dropLast(60) { days[old] = nil }\n        }\n        if counted && sound, let chime = SystemSound(rawValue: chime) { Stackbar.play(chime) }\n        if counted { Stackbar.notify(ended.mode == .focus ? \"Focus done\" : \"Break over\", body: ended.mode == .focus ? \"\\(ended.minutes) minutes on \\(ended.tag). Time for a break.\" : \"Back to it.\") }\n        mode = ended.mode == .focus ? .rest : .focus\n        pending = nil\n        session = nil\n        if autoStart { toggle() }\n    }\n\n    private func clock(_ seconds: TimeInterval) -> String {\n        let total = Int(seconds.rounded(.up))\n        return String(format: \"%d:%02d\", total / 60, total % 60)\n    }\n\n    private func minutesText(_ minutes: Int) -> String {\n        minutes < 60 ? \"\\(minutes)m\" : \"\\(minutes / 60)h \\(minutes % 60)m\"\n    }\n}\n",
  "sourceDigest" : "de69bd1e982d5899460bf319c60e5e7d19608ac1110256050d7aedcfbd8601f7"
}