added more pomidoro
This commit is contained in:
@@ -58,3 +58,43 @@ def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
|
||||
@router.get("/history")
|
||||
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
|
||||
return PomodoroService(db).history(limit=limit)
|
||||
|
||||
|
||||
@router.post("/work/start")
|
||||
def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return PomodoroService(db).start_work(
|
||||
duration_min=payload.duration_min,
|
||||
task_note=payload.task_note,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise _handle_value_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/break/short/start")
|
||||
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return PomodoroService(db).start_short_break(duration_min=duration_min)
|
||||
except ValueError as exc:
|
||||
raise _handle_value_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/break/long/start")
|
||||
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return PomodoroService(db).start_long_break(duration_min=duration_min)
|
||||
except ValueError as exc:
|
||||
raise _handle_value_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/cycle/reset")
|
||||
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict:
|
||||
return PomodoroService(db).reset_cycle(clear_task=clear_task)
|
||||
|
||||
|
||||
@router.post("/skip")
|
||||
def skip_phase(db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return PomodoroService(db).skip_phase()
|
||||
except ValueError as exc:
|
||||
raise _handle_value_error(exc) from exc
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from typing import Any
|
||||
|
||||
TOOLS_INSTRUCTIONS = """
|
||||
Ты также домашний ассистент с инструментами. Обязательные правила:
|
||||
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
|
||||
Обязательные правила:
|
||||
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
|
||||
- Никогда не выдумывай статус таймера или список задач.
|
||||
- После вызова инструмента кратко объясни результат пользователю по-человечески.
|
||||
- Инструменты: get_pomodoro_status, start_pomodoro, stop_pomodoro, get_pomodoro_history.
|
||||
- Инструменты: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
|
||||
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
|
||||
- reset_pomodoro_cycle — только когда пользователь явно просит сбросить цикл.
|
||||
""".strip()
|
||||
|
||||
DEFAULT_CARD: dict[str, Any] = {
|
||||
|
||||
+73
-11
@@ -1,12 +1,41 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db.models import PomodoroSession
|
||||
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
|
||||
|
||||
PHASE_LABELS = {
|
||||
PHASE_WORK: "Работа",
|
||||
PHASE_SHORT_BREAK: "Короткий перерыв",
|
||||
PHASE_LONG_BREAK: "Длинный перерыв",
|
||||
}
|
||||
|
||||
|
||||
def _format_time(seconds: int) -> str:
|
||||
minutes, secs = divmod(max(0, seconds), 60)
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
def format_phase_completed_notice(
|
||||
session: PomodoroSession,
|
||||
next_phase: str | None,
|
||||
) -> str:
|
||||
phase_label = PHASE_LABELS.get(session.phase, session.phase)
|
||||
task = session.task_note or "без описания"
|
||||
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
|
||||
|
||||
if next_phase == PHASE_SHORT_BREAK:
|
||||
lines.append("Дальше: короткий перерыв ☕")
|
||||
elif next_phase == PHASE_LONG_BREAK:
|
||||
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
|
||||
elif next_phase == PHASE_WORK:
|
||||
lines.append("Дальше: снова работа 💪")
|
||||
else:
|
||||
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
try:
|
||||
data = json.loads(raw_result)
|
||||
@@ -16,7 +45,23 @@ def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
return f"⏱ Помидоро: {data['error']}"
|
||||
|
||||
if tool_name in ("get_pomodoro_status", "start_pomodoro", "stop_pomodoro"):
|
||||
if tool_name == "reset_pomodoro_cycle":
|
||||
cycle = data.get("cycle", data)
|
||||
return (
|
||||
"⏱ **Цикл помидоро сброшен** · "
|
||||
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
|
||||
f"{cycle.get('sessions_until_long_break', 4)}"
|
||||
)
|
||||
|
||||
if tool_name in (
|
||||
"get_pomodoro_status",
|
||||
"start_pomodoro",
|
||||
"start_work",
|
||||
"start_short_break",
|
||||
"start_long_break",
|
||||
"stop_pomodoro",
|
||||
"skip_pomodoro_phase",
|
||||
):
|
||||
return _format_status_notice(data)
|
||||
|
||||
if tool_name == "get_pomodoro_history":
|
||||
@@ -27,31 +72,40 @@ def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
|
||||
def _format_status_notice(data: dict[str, Any]) -> str:
|
||||
status = data.get("status", "idle")
|
||||
phase = data.get("phase", PHASE_WORK)
|
||||
phase_label = PHASE_LABELS.get(phase, phase)
|
||||
task = data.get("task_note") or "без описания"
|
||||
remaining = data.get("remaining_seconds", 0)
|
||||
duration = data.get("duration_min", 25)
|
||||
cycle = data.get("cycle", {})
|
||||
cycle_info = ""
|
||||
if cycle:
|
||||
cycle_info = (
|
||||
f" · цикл {cycle.get('completed_work_sessions', 0)}/"
|
||||
f"{cycle.get('sessions_until_long_break', 4)}"
|
||||
)
|
||||
|
||||
if status == "idle":
|
||||
return "⏱ **Помидоро:** таймер не запущен."
|
||||
return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
|
||||
|
||||
if status == "running":
|
||||
return (
|
||||
f"⏱ **Помидоро запущен** · осталось **{_format_time(remaining)}** "
|
||||
f"из {duration} мин · задача: _{task}_"
|
||||
f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
|
||||
f"из {duration} мин · _{task}_{cycle_info}"
|
||||
)
|
||||
|
||||
if status == "paused":
|
||||
elapsed = data.get("elapsed_seconds", 0)
|
||||
return (
|
||||
f"⏱ **Помидоро на паузе** · прошло {_format_time(elapsed)} "
|
||||
f"из {duration} мин · задача: _{task}_"
|
||||
f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
|
||||
f"из {duration} мин · _{task}_{cycle_info}"
|
||||
)
|
||||
|
||||
if status == "completed":
|
||||
return f"⏱ **Помидоро завершён** · {duration} мин · задача: _{task}_"
|
||||
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
|
||||
|
||||
if status == "cancelled":
|
||||
return f"⏱ **Помидоро отменён** · задача: _{task}_"
|
||||
return f"⏱ **{phase_label} отменена** · _{task}_"
|
||||
|
||||
return f"⏱ Помидоро: {status}"
|
||||
|
||||
@@ -63,13 +117,21 @@ def _format_history_notice(data: Any) -> str:
|
||||
lines = ["⏱ **История помидоро:**"]
|
||||
for item in data[:10]:
|
||||
task = item.get("task_note") or "без описания"
|
||||
status = item.get("status", "?")
|
||||
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
|
||||
duration = item.get("duration_min", "?")
|
||||
lines.append(f"- {task} ({duration} мин, {status})")
|
||||
lines.append(f"- {phase}: {task} ({duration} мин)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_pomodoro_context(status: dict[str, Any]) -> str:
|
||||
notice = _format_status_notice(status)
|
||||
return f"[Актуальный статус помидоро]\n{notice}"
|
||||
cycle = status.get("cycle", {})
|
||||
extra = ""
|
||||
if cycle:
|
||||
extra = (
|
||||
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
|
||||
f"перерыв {cycle.get('short_break_min')} мин, "
|
||||
f"длинный {cycle.get('long_break_min')} мин."
|
||||
)
|
||||
return f"[Актуальный статус помидоро]\n{notice}{extra}"
|
||||
|
||||
@@ -27,8 +27,10 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def init_db() -> None:
|
||||
from app.db import models # noqa: F401
|
||||
from app.db.migrate import run_migrations
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.db.base import engine
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
inspector = inspect(engine)
|
||||
|
||||
if "pomodoro_sessions" in inspector.get_table_names():
|
||||
columns = {col["name"] for col in inspector.get_columns("pomodoro_sessions")}
|
||||
with engine.begin() as conn:
|
||||
if "phase" not in columns:
|
||||
conn.execute(
|
||||
text("ALTER TABLE pomodoro_sessions ADD COLUMN phase VARCHAR(32) DEFAULT 'work'")
|
||||
)
|
||||
if "completion_notified" not in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE pomodoro_sessions "
|
||||
"ADD COLUMN completion_notified BOOLEAN DEFAULT 0"
|
||||
)
|
||||
)
|
||||
|
||||
if "pomodoro_cycles" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {col["name"] for col in inspector.get_columns("pomodoro_cycles")}
|
||||
with engine.begin() as conn:
|
||||
if "chat_notify_seq" not in columns:
|
||||
conn.execute(
|
||||
text("ALTER TABLE pomodoro_cycles ADD COLUMN chat_notify_seq INTEGER DEFAULT 0")
|
||||
)
|
||||
@@ -35,15 +35,34 @@ class Message(Base):
|
||||
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class PomodoroCycle(Base):
|
||||
__tablename__ = "pomodoro_cycles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
||||
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
||||
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
|
||||
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class PomodoroSession(Base):
|
||||
__tablename__ = "pomodoro_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
+7
-1
@@ -1,4 +1,5 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -6,12 +7,17 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.api.routes import api_router
|
||||
from app.config import get_settings
|
||||
from app.db.base import init_db
|
||||
from app.pomodoro.watcher import pomodoro_watcher_loop
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
init_db()
|
||||
watcher_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||
yield
|
||||
watcher_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await watcher_task
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.notices import format_phase_completed_notice
|
||||
from app.db.models import ChatSession, Message, PomodoroSession
|
||||
from app.llm.client import LLMClient
|
||||
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
|
||||
from app.pomodoro.service import PomodoroService
|
||||
|
||||
PHASE_LABELS = {
|
||||
PHASE_WORK: "работа",
|
||||
PHASE_SHORT_BREAK: "короткий перерыв",
|
||||
PHASE_LONG_BREAK: "длинный перерыв",
|
||||
}
|
||||
|
||||
|
||||
class PomodoroCompletionHandler:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.pomodoro = PomodoroService(db)
|
||||
self.cycle = CycleManager(db)
|
||||
self.llm = LLMClient()
|
||||
self.character = CharacterService()
|
||||
|
||||
def _latest_chat_session_id(self) -> int | None:
|
||||
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
||||
session = self.db.scalar(stmt)
|
||||
return session.id if session else None
|
||||
|
||||
def _save_chat_message(self, session_id: int, role: str, content: str) -> None:
|
||||
self.db.add(Message(session_id=session_id, role=role, content=content))
|
||||
chat = self.db.get(ChatSession, session_id)
|
||||
if chat:
|
||||
chat.updated_at = chat.updated_at # trigger onupdate
|
||||
self.db.commit()
|
||||
|
||||
async def _generate_llm_comment(
|
||||
self,
|
||||
session: PomodoroSession,
|
||||
next_phase: str | None,
|
||||
) -> str:
|
||||
cycle = self.cycle.to_dict()
|
||||
phase_label = PHASE_LABELS.get(session.phase, session.phase)
|
||||
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
|
||||
work_done = cycle["completed_work_sessions"]
|
||||
if session.phase == PHASE_WORK:
|
||||
work_done += 1
|
||||
|
||||
system = self.character.get_system_prompt()
|
||||
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
|
||||
Задача: {session.task_note or 'без описания'}
|
||||
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
|
||||
Следующая фаза: {next_label}.
|
||||
|
||||
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown."""
|
||||
|
||||
result = await self.llm.complete(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
)
|
||||
return (result.get("content") or "").strip() or "Фаза завершена! Отличная работа."
|
||||
|
||||
def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
|
||||
phase = session.phase
|
||||
cycle = self.cycle.get()
|
||||
if phase == PHASE_WORK:
|
||||
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
|
||||
return PHASE_LONG_BREAK
|
||||
return PHASE_SHORT_BREAK
|
||||
if phase == PHASE_SHORT_BREAK:
|
||||
return PHASE_WORK
|
||||
if phase == PHASE_LONG_BREAK:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def process(self, session: PomodoroSession) -> None:
|
||||
if session.completion_notified:
|
||||
return
|
||||
|
||||
next_phase = self._resolve_next_phase(session)
|
||||
notice = format_phase_completed_notice(session, next_phase)
|
||||
|
||||
chat_id = self._latest_chat_session_id()
|
||||
if not chat_id:
|
||||
chat = ChatSession(title="Помидоро")
|
||||
self.db.add(chat)
|
||||
self.db.commit()
|
||||
self.db.refresh(chat)
|
||||
chat_id = chat.id
|
||||
|
||||
self._save_chat_message(chat_id, "notice", notice)
|
||||
|
||||
comment = await self._generate_llm_comment(session, next_phase)
|
||||
self._save_chat_message(chat_id, "assistant", comment)
|
||||
|
||||
self.cycle.bump_notify_seq()
|
||||
self.pomodoro.mark_notified(session)
|
||||
self.pomodoro.advance_after_completion(session)
|
||||
@@ -0,0 +1,89 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import PomodoroCycle
|
||||
|
||||
PHASE_WORK = "work"
|
||||
PHASE_SHORT_BREAK = "short_break"
|
||||
PHASE_LONG_BREAK = "long_break"
|
||||
|
||||
|
||||
class CycleManager:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get(self) -> PomodoroCycle:
|
||||
cycle = self.db.scalar(select(PomodoroCycle).limit(1))
|
||||
if not cycle:
|
||||
cycle = PomodoroCycle()
|
||||
self.db.add(cycle)
|
||||
self.db.commit()
|
||||
self.db.refresh(cycle)
|
||||
return cycle
|
||||
|
||||
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
|
||||
c = cycle or self.get()
|
||||
return {
|
||||
"completed_work_sessions": c.completed_work_sessions,
|
||||
"sessions_until_long_break": c.sessions_until_long_break,
|
||||
"task_note": c.task_note,
|
||||
"work_duration_min": c.work_duration_min,
|
||||
"short_break_min": c.short_break_min,
|
||||
"long_break_min": c.long_break_min,
|
||||
"auto_advance": c.auto_advance,
|
||||
"chat_notify_seq": c.chat_notify_seq,
|
||||
}
|
||||
|
||||
def reset(self, clear_task: bool = False) -> dict:
|
||||
cycle = self.get()
|
||||
cycle.completed_work_sessions = 0
|
||||
if clear_task:
|
||||
cycle.task_note = ""
|
||||
self.db.commit()
|
||||
self.db.refresh(cycle)
|
||||
return self.to_dict(cycle)
|
||||
|
||||
def bump_notify_seq(self) -> int:
|
||||
cycle = self.get()
|
||||
cycle.chat_notify_seq += 1
|
||||
self.db.commit()
|
||||
self.db.refresh(cycle)
|
||||
return cycle.chat_notify_seq
|
||||
|
||||
def on_work_completed(self) -> str:
|
||||
"""Returns next phase: short_break or long_break."""
|
||||
cycle = self.get()
|
||||
cycle.completed_work_sessions += 1
|
||||
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
|
||||
next_phase = PHASE_LONG_BREAK
|
||||
else:
|
||||
next_phase = PHASE_SHORT_BREAK
|
||||
self.db.commit()
|
||||
return next_phase
|
||||
|
||||
def on_long_break_completed(self) -> None:
|
||||
cycle = self.get()
|
||||
cycle.completed_work_sessions = 0
|
||||
self.db.commit()
|
||||
|
||||
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
|
||||
c = cycle or self.get()
|
||||
if phase == PHASE_WORK:
|
||||
return c.work_duration_min
|
||||
if phase == PHASE_SHORT_BREAK:
|
||||
return c.short_break_min
|
||||
if phase == PHASE_LONG_BREAK:
|
||||
return c.long_break_min
|
||||
return c.work_duration_min
|
||||
|
||||
def next_phase_after(self, completed_phase: str) -> str | None:
|
||||
if completed_phase == PHASE_WORK:
|
||||
cycle = self.get()
|
||||
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
|
||||
return PHASE_LONG_BREAK
|
||||
return PHASE_SHORT_BREAK
|
||||
if completed_phase == PHASE_SHORT_BREAK:
|
||||
return PHASE_WORK
|
||||
if completed_phase == PHASE_LONG_BREAK:
|
||||
return None
|
||||
return None
|
||||
+162
-27
@@ -4,6 +4,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import PomodoroSession
|
||||
from app.pomodoro.cycle import (
|
||||
PHASE_LONG_BREAK,
|
||||
PHASE_SHORT_BREAK,
|
||||
PHASE_WORK,
|
||||
CycleManager,
|
||||
)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
@@ -13,6 +19,7 @@ def _utcnow() -> datetime:
|
||||
class PomodoroService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.cycle = CycleManager(db)
|
||||
|
||||
def _get_active(self) -> PomodoroSession | None:
|
||||
stmt = (
|
||||
@@ -37,30 +44,95 @@ class PomodoroService:
|
||||
total = session.duration_min * 60
|
||||
return max(0, total - self._elapsed(session))
|
||||
|
||||
def _try_auto_complete(self, session: PomodoroSession) -> bool:
|
||||
if session.status != "running":
|
||||
return False
|
||||
if self._remaining(session) > 0:
|
||||
return False
|
||||
self._finalize_session(session, auto=True)
|
||||
return True
|
||||
|
||||
def _finalize_session(
|
||||
self,
|
||||
session: PomodoroSession,
|
||||
*,
|
||||
auto: bool,
|
||||
result: str = "",
|
||||
completed: bool | None = None,
|
||||
cancelled: bool = False,
|
||||
) -> None:
|
||||
session.elapsed_seconds = self._elapsed(session)
|
||||
session.started_at = None
|
||||
session.finished_at = _utcnow()
|
||||
session.completion_notified = False
|
||||
session.result = result or None
|
||||
|
||||
if cancelled:
|
||||
session.status = "cancelled"
|
||||
session.completed = False
|
||||
elif completed is not None:
|
||||
session.status = "completed"
|
||||
session.completed = completed
|
||||
else:
|
||||
session.status = "completed"
|
||||
session.completed = True
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
|
||||
def _start_phase(
|
||||
self,
|
||||
phase: str,
|
||||
*,
|
||||
duration_min: int | None = None,
|
||||
task_note: str | None = None,
|
||||
) -> PomodoroSession:
|
||||
active = self._get_active()
|
||||
if active:
|
||||
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
|
||||
|
||||
cycle = self.cycle.get()
|
||||
if task_note is not None:
|
||||
cycle.task_note = task_note
|
||||
elif phase == PHASE_WORK and not cycle.task_note:
|
||||
cycle.task_note = ""
|
||||
|
||||
duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
|
||||
note = task_note if task_note is not None else cycle.task_note
|
||||
|
||||
session = PomodoroSession(
|
||||
status="running",
|
||||
phase=phase,
|
||||
duration_min=duration,
|
||||
task_note=note,
|
||||
started_at=_utcnow(),
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return session
|
||||
|
||||
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
|
||||
cycle_dict = self.cycle.to_dict()
|
||||
if not session:
|
||||
return {
|
||||
"status": "idle",
|
||||
"duration_min": 25,
|
||||
"task_note": "",
|
||||
"phase": PHASE_WORK,
|
||||
"duration_min": cycle_dict["work_duration_min"],
|
||||
"task_note": cycle_dict["task_note"],
|
||||
"elapsed_seconds": 0,
|
||||
"remaining_seconds": 0,
|
||||
"session_id": None,
|
||||
"cycle": cycle_dict,
|
||||
}
|
||||
|
||||
elapsed = self._elapsed(session)
|
||||
total = session.duration_min * 60
|
||||
remaining = max(0, total - elapsed)
|
||||
|
||||
if session.status == "running" and remaining == 0:
|
||||
session.status = "completed"
|
||||
session.finished_at = _utcnow()
|
||||
session.completed = True
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
|
||||
return {
|
||||
"status": session.status,
|
||||
"phase": session.phase,
|
||||
"duration_min": session.duration_min,
|
||||
"task_note": session.task_note,
|
||||
"elapsed_seconds": elapsed,
|
||||
@@ -68,27 +140,35 @@ class PomodoroService:
|
||||
"session_id": session.id,
|
||||
"started_at": session.started_at.isoformat() if session.started_at else None,
|
||||
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
|
||||
"cycle": cycle_dict,
|
||||
}
|
||||
|
||||
def get_status(self) -> dict:
|
||||
return self._to_status_dict(self._get_active())
|
||||
|
||||
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
|
||||
active = self._get_active()
|
||||
if active:
|
||||
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
|
||||
self._try_auto_complete(active)
|
||||
active = self._get_active()
|
||||
return self._to_status_dict(active)
|
||||
|
||||
session = PomodoroSession(
|
||||
status="running",
|
||||
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
|
||||
session = self._start_phase(
|
||||
PHASE_WORK,
|
||||
duration_min=duration_min,
|
||||
task_note=task_note,
|
||||
started_at=_utcnow(),
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return self._to_status_dict(session)
|
||||
|
||||
def start_short_break(self, duration_min: int | None = None) -> dict:
|
||||
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
|
||||
return self._to_status_dict(session)
|
||||
|
||||
def start_long_break(self, duration_min: int | None = None) -> dict:
|
||||
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
|
||||
return self._to_status_dict(session)
|
||||
|
||||
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
|
||||
return self.start_work(duration_min=duration_min, task_note=task_note)
|
||||
|
||||
def pause(self) -> dict:
|
||||
session = self._get_active()
|
||||
if not session or session.status != "running":
|
||||
@@ -119,15 +199,69 @@ class PomodoroService:
|
||||
if not session:
|
||||
raise ValueError("Нет активного таймера.")
|
||||
|
||||
session.elapsed_seconds = self._elapsed(session)
|
||||
session.status = "completed" if completed else "cancelled"
|
||||
session.result = result
|
||||
session.completed = completed
|
||||
session.finished_at = _utcnow()
|
||||
session.started_at = None
|
||||
if completed:
|
||||
self._finalize_session(session, auto=False, result=result, completed=True)
|
||||
else:
|
||||
self._finalize_session(session, auto=False, result=result, cancelled=True)
|
||||
session.completion_notified = True
|
||||
self.db.commit()
|
||||
return self._to_status_dict(None)
|
||||
|
||||
def reset_cycle(self, clear_task: bool = False) -> dict:
|
||||
active = self._get_active()
|
||||
if active:
|
||||
self._finalize_session(active, auto=False, cancelled=True)
|
||||
active.completion_notified = True
|
||||
self.db.commit()
|
||||
cycle = self.cycle.reset(clear_task=clear_task)
|
||||
status = self._to_status_dict(None)
|
||||
status["cycle"] = cycle
|
||||
return status
|
||||
|
||||
def skip_phase(self) -> dict:
|
||||
session = self._get_active()
|
||||
if not session:
|
||||
raise ValueError("Нет активного таймера.")
|
||||
|
||||
self._finalize_session(session, auto=True)
|
||||
return self._to_status_dict(None)
|
||||
|
||||
def get_pending_completions(self) -> list[PomodoroSession]:
|
||||
stmt = (
|
||||
select(PomodoroSession)
|
||||
.where(
|
||||
PomodoroSession.status == "completed",
|
||||
PomodoroSession.completed.is_(True),
|
||||
PomodoroSession.completion_notified.is_(False),
|
||||
)
|
||||
.order_by(PomodoroSession.id.asc())
|
||||
)
|
||||
return list(self.db.scalars(stmt))
|
||||
|
||||
def mark_notified(self, session: PomodoroSession) -> None:
|
||||
session.completion_notified = True
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return self._to_status_dict(session)
|
||||
|
||||
def advance_after_completion(self, session: PomodoroSession) -> dict | None:
|
||||
"""Update cycle counters and auto-start next phase. Returns new status or None."""
|
||||
phase = session.phase
|
||||
cycle = self.cycle.get()
|
||||
|
||||
if phase == PHASE_WORK:
|
||||
next_phase = self.cycle.on_work_completed()
|
||||
elif phase == PHASE_SHORT_BREAK:
|
||||
next_phase = PHASE_WORK
|
||||
elif phase == PHASE_LONG_BREAK:
|
||||
self.cycle.on_long_break_completed()
|
||||
next_phase = None
|
||||
else:
|
||||
next_phase = None
|
||||
|
||||
if not cycle.auto_advance or next_phase is None:
|
||||
return None
|
||||
|
||||
new_session = self._start_phase(next_phase)
|
||||
return self._to_status_dict(new_session)
|
||||
|
||||
def history(self, limit: int = 20) -> list[dict]:
|
||||
stmt = (
|
||||
@@ -141,6 +275,7 @@ class PomodoroService:
|
||||
{
|
||||
"id": s.id,
|
||||
"status": s.status,
|
||||
"phase": s.phase,
|
||||
"duration_min": s.duration_min,
|
||||
"task_note": s.task_note,
|
||||
"result": s.result,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.pomodoro.completion import PomodoroCompletionHandler
|
||||
from app.pomodoro.service import PomodoroService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WATCH_INTERVAL_SEC = 2
|
||||
|
||||
|
||||
async def pomodoro_watcher_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
||||
await _tick()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Pomodoro watcher error")
|
||||
|
||||
|
||||
async def _tick() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
service = PomodoroService(db)
|
||||
service.get_status()
|
||||
|
||||
pending = service.get_pending_completions()
|
||||
if not pending:
|
||||
return
|
||||
|
||||
handler = PomodoroCompletionHandler(db)
|
||||
for session in pending:
|
||||
await handler.process(session)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -10,7 +10,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_status",
|
||||
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Возвращает актуальный статус помидоро.",
|
||||
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
@@ -18,18 +18,40 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_pomodoro",
|
||||
"description": "Запустить помидоро-таймер. Вызывай при каждой просьбе поставить таймер — не полагайся на память.",
|
||||
"description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {
|
||||
"type": "integer",
|
||||
"description": "Длительность в минутах, по умолчанию 25",
|
||||
},
|
||||
"task_note": {
|
||||
"type": "string",
|
||||
"description": "Над чем работаем в этой сессии",
|
||||
},
|
||||
"duration_min": {"type": "integer", "description": "Минуты работы"},
|
||||
"task_note": {"type": "string", "description": "Над чем работаем"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_short_break",
|
||||
"description": "Запустить короткий перерыв между работами.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_long_break",
|
||||
"description": "Запустить длинный перерыв после завершения цикла работ.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
@@ -39,17 +61,39 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stop_pomodoro",
|
||||
"description": "Остановить текущий помидоро-таймер",
|
||||
"description": "Остановить текущую фазу таймера.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {
|
||||
"type": "string",
|
||||
"description": "Краткий отчёт о том, что сделано",
|
||||
},
|
||||
"result": {"type": "string", "description": "Отчёт о сделанном"},
|
||||
"completed": {
|
||||
"type": "boolean",
|
||||
"description": "True если задача полностью завершена",
|
||||
"description": "True если фаза полностью завершена",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skip_pomodoro_phase",
|
||||
"description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "reset_pomodoro_cycle",
|
||||
"description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clear_task": {
|
||||
"type": "boolean",
|
||||
"description": "Также очистить текущую задачу",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
@@ -60,14 +104,11 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_history",
|
||||
"description": "ОБЯЗАТЕЛЬНО вызывай при вопросах о задачах, истории работы или что пользователь делал.",
|
||||
"description": "ОБЯЗАТЕЛЬНО при вопросах о задачах, истории работы или что пользователь делал.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Сколько последних сессий вернуть, по умолчанию 10",
|
||||
}
|
||||
"limit": {"type": "integer", "description": "Сколько сессий вернуть"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
@@ -83,15 +124,29 @@ def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "get_pomodoro_status":
|
||||
result = service.get_status()
|
||||
elif name == "start_pomodoro":
|
||||
result = service.start(
|
||||
duration_min=arguments.get("duration_min", 25),
|
||||
result = service.start_work(
|
||||
duration_min=arguments.get("duration_min"),
|
||||
task_note=arguments.get("task_note", ""),
|
||||
)
|
||||
elif name == "start_short_break":
|
||||
result = service.start_short_break(
|
||||
duration_min=arguments.get("duration_min"),
|
||||
)
|
||||
elif name == "start_long_break":
|
||||
result = service.start_long_break(
|
||||
duration_min=arguments.get("duration_min"),
|
||||
)
|
||||
elif name == "stop_pomodoro":
|
||||
result = service.stop(
|
||||
result=arguments.get("result", ""),
|
||||
completed=arguments.get("completed", False),
|
||||
)
|
||||
elif name == "skip_pomodoro_phase":
|
||||
result = service.skip_phase()
|
||||
elif name == "reset_pomodoro_cycle":
|
||||
result = service.reset_cycle(
|
||||
clear_task=arguments.get("clear_task", False),
|
||||
)
|
||||
elif name == "get_pomodoro_history":
|
||||
result = service.history(limit=arguments.get("limit", 10))
|
||||
else:
|
||||
|
||||
@@ -18,8 +18,20 @@ export interface SessionDetail extends ChatSession {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export interface PomodoroCycle {
|
||||
completed_work_sessions: number;
|
||||
sessions_until_long_break: number;
|
||||
task_note: string;
|
||||
work_duration_min: number;
|
||||
short_break_min: number;
|
||||
long_break_min: number;
|
||||
auto_advance: boolean;
|
||||
chat_notify_seq: number;
|
||||
}
|
||||
|
||||
export interface PomodoroStatus {
|
||||
status: string;
|
||||
phase: string;
|
||||
duration_min: number;
|
||||
task_note: string;
|
||||
elapsed_seconds: number;
|
||||
@@ -27,6 +39,7 @@ export interface PomodoroStatus {
|
||||
session_id: number | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
cycle: PomodoroCycle;
|
||||
}
|
||||
|
||||
export interface CharacterCardData {
|
||||
@@ -54,6 +67,7 @@ export interface CharacterCardV2 {
|
||||
export interface PomodoroHistoryItem {
|
||||
id: number;
|
||||
status: string;
|
||||
phase: string;
|
||||
duration_min: number;
|
||||
task_note: string;
|
||||
result: string | null;
|
||||
@@ -152,6 +166,26 @@ export const api = {
|
||||
|
||||
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"),
|
||||
|
||||
pomodoroResetCycle: (clear_task = false) =>
|
||||
request<PomodoroStatus>(`/api/v1/pomodoro/cycle/reset?clear_task=${clear_task}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
pomodoroSkip: () =>
|
||||
request<PomodoroStatus>("/api/v1/pomodoro/skip", { method: "POST" }),
|
||||
|
||||
pomodoroStartShortBreak: (duration_min?: number) =>
|
||||
request<PomodoroStatus>(
|
||||
`/api/v1/pomodoro/break/short/start${duration_min ? `?duration_min=${duration_min}` : ""}`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
|
||||
pomodoroStartLongBreak: (duration_min?: number) =>
|
||||
request<PomodoroStatus>(
|
||||
`/api/v1/pomodoro/break/long/start${duration_min ? `?duration_min=${duration_min}` : ""}`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
|
||||
getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
|
||||
|
||||
saveCharacter: (card: CharacterCardV2) =>
|
||||
|
||||
@@ -63,8 +63,15 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pomodoro-widget-cycle {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #8b95a5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pomodoro-widget-task {
|
||||
margin: 0.5rem 0 0;
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: #a8b0bd;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { usePomodoro } from "../hooks/usePomodoro";
|
||||
import { formatTime } from "../utils/time";
|
||||
import { phaseLabel } from "../utils/pomodoro";
|
||||
import "./PomodoroWidget.css";
|
||||
|
||||
interface PomodoroWidgetProps {
|
||||
@@ -17,22 +18,31 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps)
|
||||
const progress = isActive
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
const cycle = status.cycle;
|
||||
const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f";
|
||||
|
||||
return (
|
||||
<Link to="/pomodoro" className={`pomodoro-widget ${compact ? "compact" : ""}`}>
|
||||
<div
|
||||
className="pomodoro-widget-ring"
|
||||
style={{ background: `conic-gradient(#4f7cff ${progress}%, #1f2633 0)` }}
|
||||
style={{ background: `conic-gradient(${ringColor} ${progress}%, #1f2633 0)` }}
|
||||
>
|
||||
<div className="pomodoro-widget-inner">
|
||||
<span className="pomodoro-widget-time">{formatTime(displaySeconds)}</span>
|
||||
<span className="pomodoro-widget-label">
|
||||
{status.status === "idle" ? "помидоро" : status.status}
|
||||
{isActive ? phaseLabel(status.phase) : "помидоро"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!compact && status.task_note && (
|
||||
<p className="pomodoro-widget-task">{status.task_note}</p>
|
||||
{!compact && (
|
||||
<>
|
||||
{cycle && (
|
||||
<p className="pomodoro-widget-cycle">
|
||||
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break}
|
||||
</p>
|
||||
)}
|
||||
{status.task_note && <p className="pomodoro-widget-task">{status.task_note}</p>}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,8 @@ export default function Chat() {
|
||||
const [streaming, setStreaming] = useState("");
|
||||
const [liveNotices, setLiveNotices] = useState<string[]>([]);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { refresh: refreshPomodoro } = usePomodoro();
|
||||
const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
|
||||
const [lastNotifySeq, setLastNotifySeq] = useState(0);
|
||||
|
||||
const loadSessions = async () => {
|
||||
const data = await api.listSessions();
|
||||
@@ -55,6 +56,14 @@ export default function Chat() {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streaming, liveNotices]);
|
||||
|
||||
useEffect(() => {
|
||||
const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0;
|
||||
if (seq > lastNotifySeq && activeId) {
|
||||
setLastNotifySeq(seq);
|
||||
loadMessages(activeId).catch(console.error);
|
||||
}
|
||||
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const session = await api.createSession();
|
||||
await loadSessions();
|
||||
|
||||
@@ -51,14 +51,40 @@
|
||||
color: #c5ccd6;
|
||||
}
|
||||
|
||||
.cycle-badge {
|
||||
text-align: center;
|
||||
color: #8b95a5;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.timer-form,
|
||||
.timer-controls,
|
||||
.stop-form {
|
||||
.stop-form,
|
||||
.timer-form-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.timer-form-actions button {
|
||||
background: #2b3445;
|
||||
color: inherit;
|
||||
border: 1px solid #3a4558;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
}
|
||||
|
||||
.reset-cycle-btn {
|
||||
margin-top: 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #4a3540;
|
||||
color: #c58a8a;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.85rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timer-form label,
|
||||
.stop-form label {
|
||||
display: flex;
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client";
|
||||
import { phaseLabel } from "../utils/pomodoro";
|
||||
import { formatTime } from "../utils/time";
|
||||
import "./Pomodoro.css";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function Pomodoro() {
|
||||
const [status, setStatus] = useState<PomodoroStatus | null>(null);
|
||||
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
|
||||
@@ -21,6 +17,12 @@ export default function Pomodoro() {
|
||||
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]);
|
||||
setStatus(current);
|
||||
setHistory(past);
|
||||
if (current.cycle?.work_duration_min) {
|
||||
setDuration(current.cycle.work_duration_min);
|
||||
}
|
||||
if (current.cycle?.task_note) {
|
||||
setTaskNote(current.cycle.task_note);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,7 +33,7 @@ export default function Pomodoro() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const handleStart = async (e: FormEvent) => {
|
||||
const handleStartWork = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
@@ -67,7 +69,6 @@ export default function Pomodoro() {
|
||||
try {
|
||||
await api.pomodoroStop(result, completed);
|
||||
await refresh();
|
||||
setTaskNote("");
|
||||
setResult("");
|
||||
setCompleted(false);
|
||||
} catch (err) {
|
||||
@@ -75,28 +76,62 @@ export default function Pomodoro() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await api.pomodoroSkip();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await api.pomodoroResetCycle(false);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = status?.status === "running" || status?.status === "paused";
|
||||
const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60;
|
||||
const progress = status
|
||||
const progress = status && isActive
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
const cycle = status?.cycle;
|
||||
const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f";
|
||||
|
||||
return (
|
||||
<div className="pomodoro-page">
|
||||
<section className="timer-card">
|
||||
<div className="timer-ring" style={{ background: `conic-gradient(#4f7cff ${progress}%, #1f2633 0)` }}>
|
||||
{cycle && (
|
||||
<div className="cycle-badge">
|
||||
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break}
|
||||
{cycle.auto_advance && " · авто"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="timer-ring"
|
||||
style={{ background: `conic-gradient(${ringColor} ${progress}%, #1f2633 0)` }}
|
||||
>
|
||||
<div className="timer-inner">
|
||||
<div className="timer-value">{formatTime(displaySeconds)}</div>
|
||||
<div className="timer-status">{status?.status ?? "idle"}</div>
|
||||
<div className="timer-status">
|
||||
{isActive ? phaseLabel(status?.phase ?? "work") : status?.status ?? "idle"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.task_note && <p className="task-note">Задача: {status.task_note}</p>}
|
||||
|
||||
{!isActive ? (
|
||||
<form className="timer-form" onSubmit={handleStart}>
|
||||
<form className="timer-form" onSubmit={handleStartWork}>
|
||||
<label>
|
||||
Минут
|
||||
Работа (мин)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -113,9 +148,17 @@ export default function Pomodoro() {
|
||||
placeholder="Опишите задачу"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="primary-btn">
|
||||
Старт
|
||||
</button>
|
||||
<div className="timer-form-actions">
|
||||
<button type="submit" className="primary-btn">
|
||||
Старт работы
|
||||
</button>
|
||||
<button type="button" onClick={() => api.pomodoroStartShortBreak().then(setStatus)}>
|
||||
Короткий перерыв
|
||||
</button>
|
||||
<button type="button" onClick={() => api.pomodoroStartLongBreak().then(setStatus)}>
|
||||
Длинный перерыв
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="timer-controls">
|
||||
@@ -124,6 +167,7 @@ export default function Pomodoro() {
|
||||
) : (
|
||||
<button onClick={handleResume}>Продолжить</button>
|
||||
)}
|
||||
<button onClick={handleSkip}>Пропустить фазу</button>
|
||||
<div className="stop-form">
|
||||
<input
|
||||
value={result}
|
||||
@@ -136,13 +180,17 @@ export default function Pomodoro() {
|
||||
checked={completed}
|
||||
onChange={(e) => setCompleted(e.target.checked)}
|
||||
/>
|
||||
Задача завершена
|
||||
Фаза завершена
|
||||
</label>
|
||||
<button onClick={handleStop}>Стоп</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" className="reset-cycle-btn" onClick={handleReset}>
|
||||
Сбросить цикл
|
||||
</button>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
</section>
|
||||
|
||||
@@ -152,10 +200,11 @@ export default function Pomodoro() {
|
||||
{history.map((item) => (
|
||||
<li key={item.id}>
|
||||
<div className="history-title">
|
||||
{item.task_note || "Без описания"} — {item.status}
|
||||
{phaseLabel(item.phase)}: {item.task_note || "Без описания"} — {item.status}
|
||||
</div>
|
||||
<div className="history-meta">
|
||||
{item.duration_min} мин · {item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""}
|
||||
{item.duration_min} мин ·{" "}
|
||||
{item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""}
|
||||
</div>
|
||||
{item.result && <div className="history-result">{item.result}</div>}
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const PHASE_LABELS: Record<string, string> = {
|
||||
work: "Работа",
|
||||
short_break: "Перерыв",
|
||||
long_break: "Длинный перерыв",
|
||||
};
|
||||
|
||||
export function phaseLabel(phase: string): string {
|
||||
return PHASE_LABELS[phase] ?? phase;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/hooks/usepomodoro.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/time.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/hooks/usepomodoro.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/pomodoro.ts","./src/utils/time.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user