First Push
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
"""Pomodoro timer management: state persistence, countdown, cycle control.
|
||||
|
||||
State is stored per user (one row per user_id).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
Reference in New Issue
Block a user