Files
iot-dboard/backend/pomodoro_mgr.py
T
2026-08-11 22:07:55 +08:00

310 lines
11 KiB
Python

"""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