#!/usr/bin/env python3 """ break-status - Calculate and display break timer status for Waybar Parses the activity log to determine time until next breaks. Outputs JSON format compatible with Waybar custom modules. """ import os import sys import time import json from pathlib import Path from typing import TypedDict, NotRequired Event = tuple[int, str] Events = list[Event] class BreakConfig(TypedDict): interval: str duration: str class Config(TypedDict): short_break: BreakConfig long_break: BreakConfig snooze: NotRequired[str] class Status(TypedDict): state: str short_remaining: int long_remaining: int break_remaining: NotRequired[int] snooze_remaining: NotRequired[int] def parse_duration(duration: str) -> int: """Parse duration string like '5m' or '30s' to seconds""" if isinstance(duration, (int, float)): return int(duration) duration = duration.strip() if duration.endswith('m'): return int(duration[:-1]) * 60 elif duration.endswith('s'): return int(duration[:-1]) elif duration.endswith('h'): return int(duration[:-1]) * 3600 else: # Assume seconds if no unit return int(duration) def load_config() -> Config: """Load configuration from ~/.config/break-timer/config.json""" config_path = Path.home() / ".config" / "break-timer" / "config.json" # Default configuration default_config = { "short_break": {"interval": "20m", "duration": "20s"}, "long_break": {"interval": "45m", "duration": "5m"} } if not config_path.exists(): return default_config try: with open(config_path) as f: return json.load(f) except (json.JSONDecodeError, IOError): return default_config def get_log_path() -> Path: """Get the path to the activity log file""" runtime_dir = os.environ.get('XDG_RUNTIME_DIR') if runtime_dir: log_dir = Path(runtime_dir) / "break-timer" else: log_dir = Path.home() / ".cache" / "break-timer" return log_dir / "activity.log" def parse_log(log_path: Path) -> Events: """Parse the log file and return list of (timestamp, event_type) tuples""" events = [] if not log_path.exists(): return events try: with open(log_path, 'r') as f: for line in f: line = line.strip() if not line: continue parts = line.split(maxsplit=1) if len(parts) == 2: try: timestamp = int(parts[0]) event_type = parts[1] events.append((timestamp, event_type)) except ValueError: continue # Skip malformed lines except IOError: pass return events def format_time(seconds: int) -> str: """Format seconds as MM:SS""" if seconds < 0: seconds = 0 minutes = seconds // 60 secs = seconds % 60 return f"{minutes}:{secs:02d}" def is_paused(events: Events) -> bool: """Check if timer is currently paused (last event is 'paused')""" return bool(events and events[-1][1] == "paused") def calculate_status(events: Events, config: Config) -> Status: """ Calculate break timer status from events. Returns dict with status information. """ current_time = int(time.time()) # Check if currently paused if is_paused(events): return { "state": "paused", "short_remaining": 0, "long_remaining": 0, } # Convert config to seconds short_interval = parse_duration(config["short_break"]["interval"]) short_duration = parse_duration(config["short_break"]["duration"]) long_interval = parse_duration(config["long_break"]["interval"]) long_duration = parse_duration(config["long_break"]["duration"]) snooze_duration = parse_duration(config.get("snooze", "5m")) # If no events, assume just took breaks (timers at 0) if not events: return { "state": "working", "short_remaining": short_interval, "long_remaining": long_interval, } active_time_since_short_break = 0 active_time_since_long_break = 0 pause_events = [e for e in events if e[1] in ("paused", "unpaused")] idle_events = [e for e in events if e[1] in ("idle_start", "idle_end", "snoozed")] snooze_until = None # Helper to calculate paused time within a range def get_paused_time_in_range(start_time: int, end_time: int) -> int: """Calculate total paused time within a time range""" paused_time = 0 pause_start = None for timestamp, event_type in pause_events: if timestamp > end_time: break if event_type == "paused" and timestamp >= start_time: pause_start = timestamp elif event_type == "unpaused" and pause_start is not None: # Calculate overlap between pause period and our range overlap_start = max(pause_start, start_time) overlap_end = min(timestamp, end_time) if overlap_end > overlap_start: paused_time += overlap_end - overlap_start pause_start = None # Handle case where still paused at end of range if pause_start is not None and pause_start < end_time: paused_time += end_time - max(pause_start, start_time) return paused_time def add_work(start: int, end: int) -> None: nonlocal active_time_since_short_break, active_time_since_long_break duration = end - start - get_paused_time_in_range(start, end) active_time_since_short_break += duration active_time_since_long_break += duration def credit_break(start: int, end: int) -> None: nonlocal active_time_since_short_break, active_time_since_long_break, snooze_until if end - start >= long_duration: active_time_since_long_break = 0 if end - start >= short_duration: active_time_since_short_break = 0 snooze_until = None # Taking a break uses up the snooze # Replay the log. Exactly one of active_start / idle_start_time is set once # the first event is seen. active_start = None idle_start_time = None for timestamp, event_type in idle_events: if event_type == "snoozed": # Only counts if a break is due; snoozing again restarts it pending = 0 if active_start is not None: pending = timestamp - active_start - get_paused_time_in_range(active_start, timestamp) if (active_time_since_short_break + pending >= short_interval or active_time_since_long_break + pending >= long_interval): snooze_until = timestamp + snooze_duration elif event_type == "idle_end": if idle_start_time is not None: credit_break(idle_start_time, timestamp) idle_start_time = None if active_start is None: active_start = timestamp elif idle_start_time is None: # idle_start; ignored if already on a break if active_start is not None: add_work(active_start, timestamp) active_start = None idle_start_time = timestamp if active_start is not None: add_work(active_start, current_time) short_remaining = short_interval - active_time_since_short_break long_remaining = long_interval - active_time_since_long_break # Determine current state and calculate remaining times if idle_start_time is not None: # Currently idle - determine if in break idle_elapsed = current_time - idle_start_time if idle_elapsed >= long_duration: # Long break completed, show time until next breaks return { "state": "break_complete", "short_remaining": short_interval, # Reset "long_remaining": long_interval, # Reset } elif idle_elapsed >= short_duration: # Short break completed, long break in progress long_break_remaining = long_duration - idle_elapsed return { "state": "long_break", "break_remaining": long_break_remaining, "short_remaining": short_interval, # Already reset "long_remaining": long_remaining, # Will reset when break completed } else: # Short break in progress short_break_remaining = short_duration - idle_elapsed return { "state": "short_break", "break_remaining": short_break_remaining, "short_remaining": short_remaining, # Will reset when break completed "long_remaining": long_remaining, } # Currently working status: Status = { "state": "working", "short_remaining": short_remaining, "long_remaining": long_remaining, } if snooze_until is not None and current_time < snooze_until: status["snooze_remaining"] = snooze_until - current_time return status def format_output(status: Status): """ Format status as Waybar JSON output. https://github.com/Alexays/Waybar/wiki/Module:-Custom """ state = status["state"] if state == "paused": css_class = "paused" text = "⏸ PAUSED" tooltip = "Timer is paused. Click to unpause." elif state == "working": short_time = format_time(status["short_remaining"]) long_time = format_time(status["long_remaining"]) # Determine urgency class overdue = min(status["short_remaining"], status["long_remaining"]) if overdue <= 0 and "snooze_remaining" in status: snooze_time = format_time(status["snooze_remaining"]) css_class = "snoozed" text = f"{snooze_time} (-{format_time(-overdue)})" elif status["long_remaining"] <= 0: css_class = ["overdue", "overdue-long"] text = f"BREAK! {short_time} / {long_time}" elif status["short_remaining"] <= 0: css_class = ["overdue", "overdue-short"] text = f"BREAK! {short_time} / {long_time}" elif status["long_remaining"] < 300: # Less than 5 minutes css_class = "warning" text = f"{short_time} / {long_time}" else: css_class = "normal" text = f"{short_time} / {long_time}" tooltip = f"Short break in {short_time}\nLong break in {long_time}" if css_class == "snoozed": tooltip = f"Snoozed for {snooze_time}\n" + tooltip elif state == "short_break": break_time = format_time(status["break_remaining"]) short_time = format_time(status["short_remaining"]) css_class = "break" text = f"SB {break_time} ({short_time})" tooltip = f"Short break ends in {break_time}" elif state == "long_break": break_time = format_time(status["break_remaining"]) long_time = format_time(status["long_remaining"]) css_class = "break" text = f"LB {break_time} ({long_time})" tooltip = f"Long break ends in {break_time}" elif state == "break_complete": short_time = format_time(status["short_remaining"]) long_time = format_time(status["long_remaining"]) css_class = "break" text = f"{short_time} / {long_time}" tooltip = "Break complete. Timers reset." else: css_class = "normal" text = "???" tooltip = "Unknown state" return { "text": text, "tooltip": tooltip, "class": css_class } def main(): config = load_config() log_path = get_log_path() events = parse_log(log_path) status = calculate_status(events, config) output = format_output(status) # Waybar's format-icons picks the icon by "alt" reading = (log_path.parent / "reading").exists() if reading: output["tooltip"] += "\nReading mode: idle time counts as work" if output["class"] == "snoozed": output["alt"] = "snoozed" else: output["alt"] = "reading" if reading else "normal" print(json.dumps(output)) if __name__ == "__main__": main()