Files
wlr-break-timer/break-status

351 lines
11 KiB
Python
Executable File

#!/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
class Status(TypedDict):
state: str
short_remaining: int
long_remaining: int
break_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"])
# If no events, assume just took breaks (timers at 0)
if not events:
return {
"state": "working",
"short_remaining": short_interval,
"long_remaining": long_interval,
}
# Track active time since each type of break
active_time_since_short_break = 0
active_time_since_long_break = 0
current_state = "working"
idle_start_time = None
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")]
# 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
# Process events to calculate active time and breaks
for i, (timestamp, event_type) in enumerate(idle_events):
if event_type == "idle_start":
current_state = "idle"
idle_start_time = timestamp
if i > 0 and events[i-1][1] == "idle_end":
# Active period just ended
active_end = timestamp
active_start = events[i-1][0]
duration = active_end - active_start
# Subtract any paused time within this active period
paused_time = get_paused_time_in_range(active_start, active_end)
duration -= paused_time
# Add to both counters, they reset independently
active_time_since_short_break += duration
active_time_since_long_break += duration
elif event_type == "idle_end":
current_state = "working"
if i > 0 and events[i-1][1] == "idle_start":
# Idle period just ended
idle_end = timestamp
idle_start = events[i-1][0]
idle_duration = idle_end - idle_start
# If the idle duration qualifies as a break, reset timers
if idle_duration >= long_duration:
active_time_since_long_break = 0
if idle_duration >= short_duration:
active_time_since_short_break = 0
if current_state == "working":
# If currently working, add time since last idle_end
if events and events[-1][1] == "idle_end":
last_active_start = events[-1][0]
current_active_duration = current_time - last_active_start
# Subtract any paused time in current active period
paused_time = get_paused_time_in_range(last_active_start, current_time)
current_active_duration -= paused_time
active_time_since_short_break += current_active_duration
active_time_since_long_break += current_active_duration
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 current_state == "idle" and idle_start_time:
# 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
return {
"state": "working",
"short_remaining": short_remaining,
"long_remaining": long_remaining,
}
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
if status["short_remaining"] <= 0 or status["long_remaining"] <= 0:
css_class = "overdue"
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}"
elif state == "short_break":
break_time = format_time(status["break_remaining"])
css_class = "break"
text = f"SB {break_time}"
tooltip = f"Short break ends in {break_time}"
elif state == "long_break":
break_time = format_time(status["break_remaining"])
css_class = "break"
text = f"LB {break_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)
print(json.dumps(output))
if __name__ == "__main__":
main()