"""Pomodoro timer management: state persistence, countdown, cycle control. State is stored per user (one row per user_id). """ import time from backend.time_util import now as tz_now from flask import current_app from backend.models import get_db DEFAULT_WORK_MINUTES = 25 DEFAULT_BREAK_MINUTES = 5 DEFAULT_LONG_BREAK_MINUTES = 15 DEFAULT_CYCLES_BEFORE_LONG_BREAK = 4 def _uid() -> int: """Resolve the current user (request session or scheduler thread).""" try: from backend.auth import current_user_id return current_user_id() or 0 except Exception: return 0 def _get_db(): return get_db(current_app.config["DB_PATH"]) def get_state(user_id: int = None) -> dict: """Return current pomodoro state from DB. Cycles reset at the start of each new day (daily auto-clear). """ if user_id is None: user_id = _uid() db = _get_db() row = db.execute("SELECT * FROM pomodoro_state WHERE user_id = ?", (user_id,)).fetchone() if row is None: db.close() return _default_state() today = tz_now().strftime("%Y%m%d") cycles_date = row["cycles_date"] or "" cycles = row["cycles"] # Daily reset: if no date recorded or it's a new day, clear cycles if cycles_date != today: cycles = 0 cycles_date = today db.execute( "UPDATE pomodoro_state SET cycles=0, cycles_date=? WHERE user_id=?", (today, user_id), ) db.commit() db.close() long_break_minutes = row["long_break_minutes"] or DEFAULT_LONG_BREAK_MINUTES cycles_before_long_break = row["cycles_before_long_break"] or DEFAULT_CYCLES_BEFORE_LONG_BREAK # Compute next break type for display if row["phase"] == "work": next_break_long = ((cycles + 1) % cycles_before_long_break) == 0 else: next_break_long = ((cycles + 2) % cycles_before_long_break) == 0 return { "running": bool(row["running"]), "phase": row["phase"], # 'work' or 'break' "work_minutes": row["work_minutes"], "break_minutes": row["break_minutes"], "long_break_minutes": long_break_minutes, "cycles_before_long_break": cycles_before_long_break, "next_break_type": "long" if next_break_long else "short", "end_time": row["end_time"], "remaining_seconds": row["remaining_seconds"], "cycles": cycles, "cycles_date": cycles_date, "enabled": bool(row["enabled"]), "display_controls_enabled": bool(row["display_controls_enabled"]), "work_label": row["work_label"] or "工作中", "break_label": row["break_label"] or "休息中", } def _default_state() -> dict: return { "running": False, "phase": "work", "work_minutes": DEFAULT_WORK_MINUTES, "break_minutes": DEFAULT_BREAK_MINUTES, "long_break_minutes": DEFAULT_LONG_BREAK_MINUTES, "cycles_before_long_break": DEFAULT_CYCLES_BEFORE_LONG_BREAK, "next_break_type": "short", "end_time": None, "remaining_seconds": DEFAULT_WORK_MINUTES * 60, "cycles": 0, "enabled": False, "display_controls_enabled": True, "cycles_date": "", "work_label": "工作中", "break_label": "休息中", } def _ensure_row(db, user_id: int): row = db.execute("SELECT id FROM pomodoro_state WHERE user_id = ?", (user_id,)).fetchone() if row is None: db.execute( """INSERT INTO pomodoro_state (user_id, running, phase, work_minutes, break_minutes, long_break_minutes, cycles_before_long_break, end_time, remaining_seconds, cycles, enabled, display_controls_enabled, cycles_date, work_label, break_label) VALUES (?, 0, 'work', ?, ?, ?, ?, NULL, ?, 0, 0, 1, '', '工作中', '休息中')""", (user_id, DEFAULT_WORK_MINUTES, DEFAULT_BREAK_MINUTES, DEFAULT_LONG_BREAK_MINUTES, DEFAULT_CYCLES_BEFORE_LONG_BREAK, DEFAULT_WORK_MINUTES * 60), ) db.commit() def create_or_update(work_minutes: int = None, break_minutes: int = None, long_break_minutes: int = None, cycles_before_long_break: int = None, enabled: bool = None, display_controls_enabled: bool = None, work_label: str = None, break_label: str = None, user_id: int = None) -> dict: """Create or update pomodoro settings. Stops the timer and does not auto-start.""" if user_id is None: user_id = _uid() db = _get_db() _ensure_row(db, user_id) # Apply new settings first so the reset below uses the updated durations. fields = [] vals = [] if work_minutes is not None: fields.append("work_minutes=?") vals.append(work_minutes) if break_minutes is not None: fields.append("break_minutes=?") vals.append(break_minutes) if long_break_minutes is not None: fields.append("long_break_minutes=?") vals.append(long_break_minutes) if cycles_before_long_break is not None: fields.append("cycles_before_long_break=?") vals.append(cycles_before_long_break) if enabled is not None: fields.append("enabled=?") vals.append(1 if enabled else 0) if display_controls_enabled is not None: fields.append("display_controls_enabled=?") vals.append(1 if display_controls_enabled else 0) if work_label is not None: fields.append("work_label=?") vals.append(work_label) if break_label is not None: fields.append("break_label=?") vals.append(break_label) if fields: vals.append(user_id) db.execute(f"UPDATE pomodoro_state SET {', '.join(fields)} WHERE user_id=?", vals) # Stop the timer and reset to the work phase. Do not auto-start after saving. db.execute( """UPDATE pomodoro_state SET running=0, phase='work', end_time=NULL, remaining_seconds=work_minutes*60, enabled=0 WHERE user_id=?""", (user_id,) ) db.commit() state = get_state(user_id) db.close() current_app.event_bus.publish("pomodoro_updated", state, user_id=user_id) return state def start(user_id: int = None) -> dict: """Start or resume the pomodoro timer.""" if user_id is None: user_id = _uid() db = _get_db() _ensure_row(db, user_id) state = get_state(user_id) remaining = state.get("remaining_seconds") or (state["work_minutes"] * 60) end_time = str(time.time() + remaining) db.execute( "UPDATE pomodoro_state SET running=1, end_time=?, remaining_seconds=?, enabled=1 WHERE user_id=?", (end_time, remaining, user_id), ) db.commit() db.close() updated = get_state(user_id) current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id) return updated def pause(user_id: int = None) -> dict: """Pause the pomodoro timer, saving remaining time.""" if user_id is None: user_id = _uid() db = _get_db() _ensure_row(db, user_id) state = get_state(user_id) remaining = _calc_remaining(state) db.execute( "UPDATE pomodoro_state SET running=0, end_time=NULL, remaining_seconds=? WHERE user_id=?", (remaining, user_id), ) db.commit() db.close() updated = get_state(user_id) current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id) return updated def stop(user_id: int = None) -> dict: """Stop and reset the pomodoro timer.""" if user_id is None: user_id = _uid() db = _get_db() _ensure_row(db, user_id) db.execute( "UPDATE pomodoro_state SET running=0, phase='work', end_time=NULL, remaining_seconds=work_minutes*60, enabled=0 WHERE user_id=?", (user_id,) ) db.commit() db.close() updated = get_state(user_id) current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id) return updated def _advance_phase(user_id: int = None) -> dict: """Advance to the next phase (work ↔ break), start the timer, persist, notify. Determines short vs long break based on cycles_before_long_break setting.""" if user_id is None: user_id = _uid() db = _get_db() _ensure_row(db, user_id) state = get_state(user_id) if state["phase"] == "work": # Work → break: decide short or long cycles_before_long = state.get("cycles_before_long_break", DEFAULT_CYCLES_BEFORE_LONG_BREAK) is_long_break = ((state["cycles"] + 1) % cycles_before_long) == 0 if is_long_break: duration = state.get("long_break_minutes", DEFAULT_LONG_BREAK_MINUTES) * 60 else: duration = state["break_minutes"] * 60 next_phase = "break" cycles = state["cycles"] # cycles unchanged on work→break else: # Break → work: always use work duration duration = state["work_minutes"] * 60 next_phase = "work" cycles = state["cycles"] + 1 end_time = str(time.time() + duration) today = tz_now().strftime("%Y%m%d") db.execute( "UPDATE pomodoro_state SET running=1, phase=?, end_time=?, remaining_seconds=?, cycles=?, cycles_date=? WHERE user_id=?", (next_phase, end_time, duration, cycles, today, user_id), ) db.commit() db.close() updated = get_state(user_id) current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id) return updated def skip(user_id: int = None) -> dict: """Skip the current phase and start the next phase immediately (work ↔ break).""" return _advance_phase(user_id) def dismiss(user_id: int = None) -> dict: """Dismiss ringing and auto-advance to next phase.""" return _advance_phase(user_id) def _calc_remaining(state: dict) -> int: """Calculate remaining seconds from state.""" if not state.get("running"): return state.get("remaining_seconds", state["work_minutes"] * 60) end_time_str = state.get("end_time") if not end_time_str: return state.get("remaining_seconds", state["work_minutes"] * 60) try: remaining = int(float(end_time_str) - time.time()) return max(0, remaining) except (ValueError, TypeError): return state.get("remaining_seconds", state["work_minutes"] * 60) def check_and_trigger(user_id: int = None) -> bool: """Called by scheduler to check if pomodoro timer has expired. Returns True if triggered (ringing should start).""" if user_id is None: user_id = _uid() db = _get_db() _ensure_row(db, user_id) state = get_state(user_id) db.close() if not state.get("running") or not state.get("enabled"): return False remaining = _calc_remaining(state) if remaining > 0: return False # Timer expired; auto-advance phase on the backend once, then notify displays. updated = dismiss(user_id) current_app.event_bus.publish("pomodoro_due", updated, user_id=user_id) return True