added more pomidoro

This commit is contained in:
2026-06-09 11:54:32 +03:00
parent 244935e4ac
commit c8599b3d13
20 changed files with 817 additions and 91 deletions
+40
View File
@@ -58,3 +58,43 @@ def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
@router.get("/history") @router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]: def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
return PomodoroService(db).history(limit=limit) 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
+5 -2
View File
@@ -1,11 +1,14 @@
from typing import Any from typing import Any
TOOLS_INSTRUCTIONS = """ 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() """.strip()
DEFAULT_CARD: dict[str, Any] = { DEFAULT_CARD: dict[str, Any] = {
+73 -11
View File
@@ -1,12 +1,41 @@
import json import json
from typing import Any 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: def _format_time(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60) minutes, secs = divmod(max(0, seconds), 60)
return f"{minutes:02d}:{secs:02d}" 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: def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None:
try: try:
data = json.loads(raw_result) 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: if isinstance(data, dict) and "error" in data:
return f"⏱ Помидоро: {data['error']}" 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) return _format_status_notice(data)
if tool_name == "get_pomodoro_history": 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: def _format_status_notice(data: dict[str, Any]) -> str:
status = data.get("status", "idle") status = data.get("status", "idle")
phase = data.get("phase", PHASE_WORK)
phase_label = PHASE_LABELS.get(phase, phase)
task = data.get("task_note") or "без описания" task = data.get("task_note") or "без описания"
remaining = data.get("remaining_seconds", 0) remaining = data.get("remaining_seconds", 0)
duration = data.get("duration_min", 25) 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": if status == "idle":
return "⏱ **Помидоро:** таймер не запущен." return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
if status == "running": if status == "running":
return ( return (
f"⏱ **Помидоро запущен** · осталось **{_format_time(remaining)}** " f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
f"из {duration} мин · задача: _{task}_" f"из {duration} мин · _{task}_{cycle_info}"
) )
if status == "paused": if status == "paused":
elapsed = data.get("elapsed_seconds", 0) elapsed = data.get("elapsed_seconds", 0)
return ( return (
f"⏱ **Помидоро на паузе** · прошло {_format_time(elapsed)} " f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
f"из {duration} мин · задача: _{task}_" f"из {duration} мин · _{task}_{cycle_info}"
) )
if status == "completed": if status == "completed":
return f"⏱ **Помидоро завершён** · {duration} мин · задача: _{task}_" return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
if status == "cancelled": if status == "cancelled":
return f"⏱ **Помидоро отменён** · задача: _{task}_" return f"⏱ **{phase_label} отменена** · _{task}_"
return f"⏱ Помидоро: {status}" return f"⏱ Помидоро: {status}"
@@ -63,13 +117,21 @@ def _format_history_notice(data: Any) -> str:
lines = ["⏱ **История помидоро:**"] lines = ["⏱ **История помидоро:**"]
for item in data[:10]: for item in data[:10]:
task = item.get("task_note") or "без описания" 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", "?") duration = item.get("duration_min", "?")
lines.append(f"- {task} ({duration} мин, {status})") lines.append(f"- {phase}: {task} ({duration} мин)")
return "\n".join(lines) return "\n".join(lines)
def format_pomodoro_context(status: dict[str, Any]) -> str: def format_pomodoro_context(status: dict[str, Any]) -> str:
notice = _format_status_notice(status) 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}"
+2
View File
@@ -27,8 +27,10 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db() -> None: def init_db() -> None:
from app.db import models # noqa: F401 from app.db import models # noqa: F401
from app.db.migrate import run_migrations
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations()
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:
+32
View File
@@ -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")
)
+19
View File
@@ -35,15 +35,34 @@ class Message(Base):
session: Mapped["ChatSession"] = relationship(back_populates="messages") 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): class PomodoroSession(Base):
__tablename__ = "pomodoro_sessions" __tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
status: Mapped[str] = mapped_column(String(32), default="idle") 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) duration_min: Mapped[int] = mapped_column(Integer, default=25)
task_note: Mapped[str] = mapped_column(Text, default="") task_note: Mapped[str] = mapped_column(Text, default="")
result: Mapped[str | None] = mapped_column(Text, nullable=True) result: Mapped[str | None] = mapped_column(Text, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, default=False) 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) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paused_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) elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
+7 -1
View File
@@ -1,4 +1,5 @@
from contextlib import asynccontextmanager import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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.api.routes import api_router
from app.config import get_settings from app.config import get_settings
from app.db.base import init_db from app.db.base import init_db
from app.pomodoro.watcher import pomodoro_watcher_loop
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
init_db() init_db()
watcher_task = asyncio.create_task(pomodoro_watcher_loop())
yield yield
watcher_task.cancel()
with suppress(asyncio.CancelledError):
await watcher_task
def create_app() -> FastAPI: def create_app() -> FastAPI:
+101
View File
@@ -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)
+89
View File
@@ -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
View File
@@ -4,6 +4,12 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import PomodoroSession from app.db.models import PomodoroSession
from app.pomodoro.cycle import (
PHASE_LONG_BREAK,
PHASE_SHORT_BREAK,
PHASE_WORK,
CycleManager,
)
def _utcnow() -> datetime: def _utcnow() -> datetime:
@@ -13,6 +19,7 @@ def _utcnow() -> datetime:
class PomodoroService: class PomodoroService:
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db self.db = db
self.cycle = CycleManager(db)
def _get_active(self) -> PomodoroSession | None: def _get_active(self) -> PomodoroSession | None:
stmt = ( stmt = (
@@ -37,30 +44,95 @@ class PomodoroService:
total = session.duration_min * 60 total = session.duration_min * 60
return max(0, total - self._elapsed(session)) 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: def _to_status_dict(self, session: PomodoroSession | None) -> dict:
cycle_dict = self.cycle.to_dict()
if not session: if not session:
return { return {
"status": "idle", "status": "idle",
"duration_min": 25, "phase": PHASE_WORK,
"task_note": "", "duration_min": cycle_dict["work_duration_min"],
"task_note": cycle_dict["task_note"],
"elapsed_seconds": 0, "elapsed_seconds": 0,
"remaining_seconds": 0, "remaining_seconds": 0,
"session_id": None, "session_id": None,
"cycle": cycle_dict,
} }
elapsed = self._elapsed(session) elapsed = self._elapsed(session)
total = session.duration_min * 60 total = session.duration_min * 60
remaining = max(0, total - elapsed) 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 { return {
"status": session.status, "status": session.status,
"phase": session.phase,
"duration_min": session.duration_min, "duration_min": session.duration_min,
"task_note": session.task_note, "task_note": session.task_note,
"elapsed_seconds": elapsed, "elapsed_seconds": elapsed,
@@ -68,27 +140,35 @@ class PomodoroService:
"session_id": session.id, "session_id": session.id,
"started_at": session.started_at.isoformat() if session.started_at else None, "started_at": session.started_at.isoformat() if session.started_at else None,
"finished_at": session.finished_at.isoformat() if session.finished_at else None, "finished_at": session.finished_at.isoformat() if session.finished_at else None,
"cycle": cycle_dict,
} }
def get_status(self) -> 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() active = self._get_active()
if active: if active:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.") self._try_auto_complete(active)
active = self._get_active()
return self._to_status_dict(active)
session = PomodoroSession( def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
status="running", session = self._start_phase(
PHASE_WORK,
duration_min=duration_min, duration_min=duration_min,
task_note=task_note, task_note=task_note,
started_at=_utcnow(),
) )
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(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: def pause(self) -> dict:
session = self._get_active() session = self._get_active()
if not session or session.status != "running": if not session or session.status != "running":
@@ -119,15 +199,69 @@ class PomodoroService:
if not session: if not session:
raise ValueError("Нет активного таймера.") raise ValueError("Нет активного таймера.")
session.elapsed_seconds = self._elapsed(session) if completed:
session.status = "completed" if completed else "cancelled" self._finalize_session(session, auto=False, result=result, completed=True)
session.result = result else:
session.completed = completed self._finalize_session(session, auto=False, result=result, cancelled=True)
session.finished_at = _utcnow() session.completion_notified = True
session.started_at = None
self.db.commit() self.db.commit()
self.db.refresh(session) return self._to_status_dict(None)
return self._to_status_dict(session)
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()
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]: def history(self, limit: int = 20) -> list[dict]:
stmt = ( stmt = (
@@ -141,6 +275,7 @@ class PomodoroService:
{ {
"id": s.id, "id": s.id,
"status": s.status, "status": s.status,
"phase": s.phase,
"duration_min": s.duration_min, "duration_min": s.duration_min,
"task_note": s.task_note, "task_note": s.task_note,
"result": s.result, "result": s.result,
+38
View File
@@ -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()
+76 -21
View File
@@ -10,7 +10,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "get_pomodoro_status", "name": "get_pomodoro_status",
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Возвращает актуальный статус помидоро.", "description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.",
"parameters": {"type": "object", "properties": {}, "required": []}, "parameters": {"type": "object", "properties": {}, "required": []},
}, },
}, },
@@ -18,19 +18,41 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "start_pomodoro", "name": "start_pomodoro",
"description": "Запустить помидоро-таймер. Вызывай при каждой просьбе поставить таймер — не полагайся на память.", "description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"duration_min": { "duration_min": {"type": "integer", "description": "Минуты работы"},
"type": "integer", "task_note": {"type": "string", "description": "Над чем работаем"},
"description": "Длительность в минутах, по умолчанию 25",
}, },
"task_note": { "required": [],
"type": "string",
"description": "Над чем работаем в этой сессии",
}, },
}, },
},
{
"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": [], "required": [],
}, },
}, },
@@ -39,17 +61,39 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "stop_pomodoro", "name": "stop_pomodoro",
"description": "Остановить текущий помидоро-таймер", "description": "Остановить текущую фазу таймера.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"result": { "result": {"type": "string", "description": "Отчёт о сделанном"},
"type": "string",
"description": "Краткий отчёт о том, что сделано",
},
"completed": { "completed": {
"type": "boolean", "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": [], "required": [],
@@ -60,14 +104,11 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "get_pomodoro_history", "name": "get_pomodoro_history",
"description": "ОБЯЗАТЕЛЬНО вызывай при вопросах о задачах, истории работы или что пользователь делал.", "description": "ОБЯЗАТЕЛЬНО при вопросах о задачах, истории работы или что пользователь делал.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"limit": { "limit": {"type": "integer", "description": "Сколько сессий вернуть"},
"type": "integer",
"description": "Сколько последних сессий вернуть, по умолчанию 10",
}
}, },
"required": [], "required": [],
}, },
@@ -83,15 +124,29 @@ def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str:
if name == "get_pomodoro_status": if name == "get_pomodoro_status":
result = service.get_status() result = service.get_status()
elif name == "start_pomodoro": elif name == "start_pomodoro":
result = service.start( result = service.start_work(
duration_min=arguments.get("duration_min", 25), duration_min=arguments.get("duration_min"),
task_note=arguments.get("task_note", ""), 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": elif name == "stop_pomodoro":
result = service.stop( result = service.stop(
result=arguments.get("result", ""), result=arguments.get("result", ""),
completed=arguments.get("completed", False), 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": elif name == "get_pomodoro_history":
result = service.history(limit=arguments.get("limit", 10)) result = service.history(limit=arguments.get("limit", 10))
else: else:
+34
View File
@@ -18,8 +18,20 @@ export interface SessionDetail extends ChatSession {
messages: ChatMessage[]; 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 { export interface PomodoroStatus {
status: string; status: string;
phase: string;
duration_min: number; duration_min: number;
task_note: string; task_note: string;
elapsed_seconds: number; elapsed_seconds: number;
@@ -27,6 +39,7 @@ export interface PomodoroStatus {
session_id: number | null; session_id: number | null;
started_at?: string | null; started_at?: string | null;
finished_at?: string | null; finished_at?: string | null;
cycle: PomodoroCycle;
} }
export interface CharacterCardData { export interface CharacterCardData {
@@ -54,6 +67,7 @@ export interface CharacterCardV2 {
export interface PomodoroHistoryItem { export interface PomodoroHistoryItem {
id: number; id: number;
status: string; status: string;
phase: string;
duration_min: number; duration_min: number;
task_note: string; task_note: string;
result: string | null; result: string | null;
@@ -152,6 +166,26 @@ export const api = {
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"), 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"), getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
saveCharacter: (card: CharacterCardV2) => saveCharacter: (card: CharacterCardV2) =>
+8 -1
View File
@@ -63,8 +63,15 @@
display: none; display: none;
} }
.pomodoro-widget-cycle {
margin: 0.45rem 0 0;
font-size: 0.75rem;
color: #8b95a5;
text-align: center;
}
.pomodoro-widget-task { .pomodoro-widget-task {
margin: 0.5rem 0 0; margin: 0.25rem 0 0;
font-size: 0.8rem; font-size: 0.8rem;
color: #a8b0bd; color: #a8b0bd;
text-align: center; text-align: center;
+14 -4
View File
@@ -1,6 +1,7 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { usePomodoro } from "../hooks/usePomodoro"; import { usePomodoro } from "../hooks/usePomodoro";
import { formatTime } from "../utils/time"; import { formatTime } from "../utils/time";
import { phaseLabel } from "../utils/pomodoro";
import "./PomodoroWidget.css"; import "./PomodoroWidget.css";
interface PomodoroWidgetProps { interface PomodoroWidgetProps {
@@ -17,22 +18,31 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps)
const progress = isActive const progress = isActive
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
: 0; : 0;
const cycle = status.cycle;
const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f";
return ( return (
<Link to="/pomodoro" className={`pomodoro-widget ${compact ? "compact" : ""}`}> <Link to="/pomodoro" className={`pomodoro-widget ${compact ? "compact" : ""}`}>
<div <div
className="pomodoro-widget-ring" 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"> <div className="pomodoro-widget-inner">
<span className="pomodoro-widget-time">{formatTime(displaySeconds)}</span> <span className="pomodoro-widget-time">{formatTime(displaySeconds)}</span>
<span className="pomodoro-widget-label"> <span className="pomodoro-widget-label">
{status.status === "idle" ? "помидоро" : status.status} {isActive ? phaseLabel(status.phase) : "помидоро"}
</span> </span>
</div> </div>
</div> </div>
{!compact && status.task_note && ( {!compact && (
<p className="pomodoro-widget-task">{status.task_note}</p> <>
{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> </Link>
); );
+10 -1
View File
@@ -26,7 +26,8 @@ export default function Chat() {
const [streaming, setStreaming] = useState(""); const [streaming, setStreaming] = useState("");
const [liveNotices, setLiveNotices] = useState<string[]>([]); const [liveNotices, setLiveNotices] = useState<string[]>([]);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const { refresh: refreshPomodoro } = usePomodoro(); const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
const [lastNotifySeq, setLastNotifySeq] = useState(0);
const loadSessions = async () => { const loadSessions = async () => {
const data = await api.listSessions(); const data = await api.listSessions();
@@ -55,6 +56,14 @@ export default function Chat() {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streaming, liveNotices]); }, [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 handleNewChat = async () => {
const session = await api.createSession(); const session = await api.createSession();
await loadSessions(); await loadSessions();
+27 -1
View File
@@ -51,14 +51,40 @@
color: #c5ccd6; color: #c5ccd6;
} }
.cycle-badge {
text-align: center;
color: #8b95a5;
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.timer-form, .timer-form,
.timer-controls, .timer-controls,
.stop-form { .stop-form,
.timer-form-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; 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, .timer-form label,
.stop-form label { .stop-form label {
display: flex; display: flex;
+66 -17
View File
@@ -1,13 +1,9 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client"; import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client";
import { phaseLabel } from "../utils/pomodoro";
import { formatTime } from "../utils/time";
import "./Pomodoro.css"; 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() { export default function Pomodoro() {
const [status, setStatus] = useState<PomodoroStatus | null>(null); const [status, setStatus] = useState<PomodoroStatus | null>(null);
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]); const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
@@ -21,6 +17,12 @@ export default function Pomodoro() {
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]); const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]);
setStatus(current); setStatus(current);
setHistory(past); 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(() => { useEffect(() => {
@@ -31,7 +33,7 @@ export default function Pomodoro() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const handleStart = async (e: FormEvent) => { const handleStartWork = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { try {
@@ -67,7 +69,6 @@ export default function Pomodoro() {
try { try {
await api.pomodoroStop(result, completed); await api.pomodoroStop(result, completed);
await refresh(); await refresh();
setTaskNote("");
setResult(""); setResult("");
setCompleted(false); setCompleted(false);
} catch (err) { } 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 isActive = status?.status === "running" || status?.status === "paused";
const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60; 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 ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
: 0; : 0;
const cycle = status?.cycle;
const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f";
return ( return (
<div className="pomodoro-page"> <div className="pomodoro-page">
<section className="timer-card"> <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-inner">
<div className="timer-value">{formatTime(displaySeconds)}</div> <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>
</div> </div>
{status?.task_note && <p className="task-note">Задача: {status.task_note}</p>} {status?.task_note && <p className="task-note">Задача: {status.task_note}</p>}
{!isActive ? ( {!isActive ? (
<form className="timer-form" onSubmit={handleStart}> <form className="timer-form" onSubmit={handleStartWork}>
<label> <label>
Минут Работа (мин)
<input <input
type="number" type="number"
min={1} min={1}
@@ -113,9 +148,17 @@ export default function Pomodoro() {
placeholder="Опишите задачу" placeholder="Опишите задачу"
/> />
</label> </label>
<div className="timer-form-actions">
<button type="submit" className="primary-btn"> <button type="submit" className="primary-btn">
Старт Старт работы
</button> </button>
<button type="button" onClick={() => api.pomodoroStartShortBreak().then(setStatus)}>
Короткий перерыв
</button>
<button type="button" onClick={() => api.pomodoroStartLongBreak().then(setStatus)}>
Длинный перерыв
</button>
</div>
</form> </form>
) : ( ) : (
<div className="timer-controls"> <div className="timer-controls">
@@ -124,6 +167,7 @@ export default function Pomodoro() {
) : ( ) : (
<button onClick={handleResume}>Продолжить</button> <button onClick={handleResume}>Продолжить</button>
)} )}
<button onClick={handleSkip}>Пропустить фазу</button>
<div className="stop-form"> <div className="stop-form">
<input <input
value={result} value={result}
@@ -136,13 +180,17 @@ export default function Pomodoro() {
checked={completed} checked={completed}
onChange={(e) => setCompleted(e.target.checked)} onChange={(e) => setCompleted(e.target.checked)}
/> />
Задача завершена Фаза завершена
</label> </label>
<button onClick={handleStop}>Стоп</button> <button onClick={handleStop}>Стоп</button>
</div> </div>
</div> </div>
)} )}
<button type="button" className="reset-cycle-btn" onClick={handleReset}>
Сбросить цикл
</button>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
</section> </section>
@@ -152,10 +200,11 @@ export default function Pomodoro() {
{history.map((item) => ( {history.map((item) => (
<li key={item.id}> <li key={item.id}>
<div className="history-title"> <div className="history-title">
{item.task_note || "Без описания"} {item.status} {phaseLabel(item.phase)}: {item.task_note || "Без описания"} {item.status}
</div> </div>
<div className="history-meta"> <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> </div>
{item.result && <div className="history-result">{item.result}</div>} {item.result && <div className="history-result">{item.result}</div>}
</li> </li>
+9
View File
@@ -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
View File
@@ -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"}