Files
tgrosinger 194029d713 End reading-mode breaks only on input
Breaks started from the menu in reading mode used to end on
their own once the due break's length passed, so work started
counting before the user was back. They now last until input,
which makes them identical to an ordinary idle_start, so the
reading_break event and its cap logic are gone.
2026-11-05 12:27:44 -08:00

250 lines
7.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""
break-event - Log idle state transitions and auto-trim old events
Called by break-idle and swayidle to record when the user goes idle or resumes
activity, and by the Waybar menu. Automatically trims log entries before
qualifying break periods to keep log small.
In reading mode (a "reading" flag file next to the log), automatic idle is
ignored so hands-off reading counts as work. Breaks are started from the menu
with start-break and end on input.
"""
import os
import sys
import time
import fcntl
import json
from pathlib import Path
from typing import TypedDict
Event = tuple[int, str]
Events = list[Event]
class BreakConfig(TypedDict):
interval: str
duration: str
class Config(TypedDict):
short_break: BreakConfig
long_break: BreakConfig
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:
# Probably /run/user/$UID/break-timer
log_dir = Path(runtime_dir) / "break-timer"
else:
log_dir = Path.home() / ".cache" / "break-timer"
log_dir.mkdir(parents=True, exist_ok=True)
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 trim_log(events: Events, long_break_duration: int) -> Events:
"""
Trim events before qualifying long breaks.
Returns filtered list of events.
"""
if len(events) < 2:
return events
# Find the most recent idle period that qualifies as a long break
last_qualifying_idle_index = None
for i in range(len(events) - 1):
timestamp, event_type = events[i]
if event_type == "idle_start":
# Find the corresponding idle_end
for j in range(i + 1, len(events)):
next_timestamp, next_event_type = events[j]
if next_event_type == "idle_end":
idle_duration = next_timestamp - timestamp
if idle_duration >= long_break_duration:
last_qualifying_idle_index = j
break
elif next_event_type == "idle_start":
# No idle_end found, check if still idle
break
# Also check if currently idle and duration qualifies
if events and events[-1][1] == "idle_start":
idle_start_time = events[-1][0]
current_time = int(time.time())
if current_time - idle_start_time >= long_break_duration:
last_qualifying_idle_index = len(events) - 1
# Trim everything before the qualifying idle_start
if last_qualifying_idle_index is not None:
return events[last_qualifying_idle_index:]
return events
def write_log(log_path: Path, events: Events) -> None:
"""Write events to log file atomically"""
temp_path = log_path.with_suffix('.tmp')
try:
with open(temp_path, 'w') as f:
for timestamp, event_type in events:
f.write(f"{timestamp} {event_type}\n")
# Atomic rename
temp_path.rename(log_path)
except IOError as e:
print(f"Error writing log: {e}", file=sys.stderr)
if temp_path.exists():
temp_path.unlink()
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 in_break(events: Events) -> bool:
"""Whether the last idle-related event started a break. With none (e.g.
after skip_long) there is no work period yet, so idle_end must be logged."""
for _, event_type in reversed(events):
if event_type == "idle_start":
return True
if event_type == "idle_end":
return False
return True
COMMANDS = ("idle_start", "idle_end", "sleep", "wake", "start-break", "snooze",
"skip_long", "toggle-pause", "toggle-reading")
def main():
if len(sys.argv) != 2 or sys.argv[1] not in COMMANDS:
print(f"Usage: break-event <{'|'.join(COMMANDS)}>", file=sys.stderr)
sys.exit(1)
event_type = sys.argv[1]
config = load_config()
log_path = get_log_path()
lock_path = log_path.parent / "break-timer.lock"
reading_path = log_path.parent / "reading"
# Convert config to seconds
long_break_duration = parse_duration(config["long_break"]["duration"])
# Acquire lock for atomic operation
lock_path.touch()
with open(lock_path, 'w') as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
# Read current events
events = parse_log(log_path)
try:
if event_type == "skip_long":
# Erase all events from the log, effectively resetting all timers.
write_log(log_path, [])
elif event_type == "toggle-pause":
current_time = int(time.time())
event_key = "paused" if not is_paused(events) else "unpaused"
events.append((current_time, event_key))
write_log(log_path, events)
elif event_type == "toggle-reading":
if reading_path.exists():
reading_path.unlink()
else:
reading_path.touch()
elif not is_paused(events): # Don't log idle events when paused
reading = reading_path.exists()
if event_type == "start-break":
logged = "idle_start"
elif event_type == "snooze":
logged = "snoozed"
elif event_type in ("sleep", "wake"):
logged = "idle_start" if event_type == "sleep" else "idle_end"
elif reading and event_type == "idle_start":
logged = None
elif reading and event_type == "idle_end" and not in_break(events):
logged = None
else:
logged = event_type
if logged:
events.append((int(time.time()), logged))
events = trim_log(events, long_break_duration)
write_log(log_path, events)
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
if __name__ == "__main__":
main()