added reminder

This commit is contained in:
2026-06-11 11:04:22 +03:00
parent 363aca293a
commit f7cc238308
22 changed files with 1265 additions and 2 deletions
+1
View File
@@ -30,6 +30,7 @@ MEMORY_AUTO_EXTRACT=true
WGER_BASE_URL=https://wger.de/api/v2 WGER_BASE_URL=https://wger.de/api/v2
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
FITNESS_REMINDERS_ENABLED=true FITNESS_REMINDERS_ENABLED=true
REMINDERS_ENABLED=true
# Taiga (on host :9000, nginx → taiga.grigowashere.ru) # Taiga (on host :9000, nginx → taiga.grigowashere.ru)
TAIGA_BASE_URL=http://host.docker.internal:9000 TAIGA_BASE_URL=http://host.docker.internal:9000
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, shopping, webhooks from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"]) api_router.include_router(health.router, tags=["health"])
@@ -12,5 +12,6 @@ api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"]) api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"]) api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"]) api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"]) api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"]) api_router.include_router(media.router, tags=["media"])
+124
View File
@@ -0,0 +1,124 @@
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.homelab.context import resolve_timezone
from app.reminders.service import RemindersService
router = APIRouter()
class ReminderCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00")
notes: str = ""
all_day: bool = False
recurrence: str = "none"
class ReminderUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
due_at: str | None = None
notes: str | None = None
all_day: bool | None = None
recurrence: str | None = None
enabled: bool | None = None
@router.get("")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return RemindersService(db).snapshot()
@router.get("/upcoming")
def list_upcoming(
limit: int = Query(30, ge=1, le=100),
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return RemindersService(db).list_upcoming(limit=limit)
@router.get("/calendar")
def calendar(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
db: Session = Depends(get_db),
) -> dict[str, Any]:
tz_name = resolve_timezone(db)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
start = datetime(year, month, 1, tzinfo=tz)
if month == 12:
end = datetime(year + 1, 1, 1, tzinfo=tz)
else:
end = datetime(year, month + 1, 1, tzinfo=tz)
service = RemindersService(db)
items = service.list_in_range(
date_from=start.astimezone(timezone.utc),
date_to=end.astimezone(timezone.utc),
)
return {
"year": year,
"month": month,
"timezone": tz_name,
"reminders": items,
}
@router.post("")
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).create(
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/{reminder_id}")
def update_reminder(
reminder_id: int,
payload: ReminderUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return RemindersService(db).update(
reminder_id,
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
enabled=payload.enabled,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/{reminder_id}")
def delete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).delete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{reminder_id}/complete")
def complete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).complete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+3
View File
@@ -25,6 +25,9 @@ TOOLS_INSTRUCTIONS = """
- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй. - Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list. - Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list.
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки. - «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
- «Напомни через 15 минут», «завтра утром», «12 мая 2027 в 12:16» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]).
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе.
""".strip() """.strip()
DEFAULT_CARD: dict[str, Any] = { DEFAULT_CARD: dict[str, Any] = {
+28
View File
@@ -69,6 +69,14 @@ FITNESS_TOOL_NAMES = frozenset({
}) })
# Не засорять чат служебными ответами # Не засорять чат служебными ответами
REMINDER_TOOL_NAMES = frozenset({
"list_reminders",
"create_reminder",
"update_reminder",
"delete_reminder",
"complete_reminder",
})
SHOPPING_TOOL_NAMES = frozenset({ SHOPPING_TOOL_NAMES = frozenset({
"list_shopping_lists", "list_shopping_lists",
"create_shopping_list", "create_shopping_list",
@@ -88,6 +96,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_weather", "get_weather",
"get_morning_briefing", "get_morning_briefing",
"list_shopping_lists", "list_shopping_lists",
"list_reminders",
}) })
@@ -109,6 +118,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
prefix = "💪" prefix = "💪"
elif tool_name in SHOPPING_TOOL_NAMES: elif tool_name in SHOPPING_TOOL_NAMES:
prefix = "🛒" prefix = "🛒"
elif tool_name in REMINDER_TOOL_NAMES:
prefix = "📅"
else: else:
prefix = "📋" prefix = "📋"
return f"{prefix} {data['error']}" return f"{prefix} {data['error']}"
@@ -231,6 +242,23 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name == "delete_shopping_list" and data.get("ok"): if tool_name == "delete_shopping_list" and data.get("ok"):
return f"🛒 **Список удалён** · «{data.get('name')}»" return f"🛒 **Список удалён** · «{data.get('name')}»"
if tool_name == "create_reminder" and data.get("ok"):
r = data.get("reminder") or {}
rec = r.get("recurrence", "none")
rec_label = f" · повтор {rec}" if rec and rec != "none" else ""
return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}"
if tool_name == "update_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}"
if tool_name == "delete_reminder" and data.get("ok"):
return f"📅 **Напоминание удалено** · «{data.get('title')}»"
if tool_name == "complete_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Готово** · {r.get('title')}"
return None return None
+3
View File
@@ -28,6 +28,7 @@ from app.memory.context import (
) )
from app.memory.extract import extract_after_turn from app.memory.extract import extract_after_turn
from app.projects.context import format_projects_context, get_projects_snapshot from app.projects.context import format_projects_context, get_projects_snapshot
from app.reminders.context import format_reminders_context, get_reminders_snapshot
from app.shopping.context import format_shopping_context, get_shopping_snapshot from app.shopping.context import format_shopping_context, get_shopping_snapshot
from app.db.models import ChatSession, Message from app.db.models import ChatSession, Message
from app.llm.client import LLMClient from app.llm.client import LLMClient
@@ -99,6 +100,7 @@ class ChatService:
memory_snapshot = get_memory_snapshot(self.db, session_id) memory_snapshot = get_memory_snapshot(self.db, session_id)
fitness_snapshot = get_fitness_snapshot(self.db) fitness_snapshot = get_fitness_snapshot(self.db)
shopping_snapshot = get_shopping_snapshot(self.db) shopping_snapshot = get_shopping_snapshot(self.db)
reminders_snapshot = get_reminders_snapshot(self.db)
projects_snapshot = get_projects_snapshot(self.db) projects_snapshot = get_projects_snapshot(self.db)
return ( return (
f"{self.character.get_system_prompt()}\n\n" f"{self.character.get_system_prompt()}\n\n"
@@ -106,6 +108,7 @@ class ChatService:
f"{format_memory_context(memory_snapshot)}\n\n" f"{format_memory_context(memory_snapshot)}\n\n"
f"{format_fitness_context(fitness_snapshot)}\n\n" f"{format_fitness_context(fitness_snapshot)}\n\n"
f"{format_shopping_context(shopping_snapshot)}\n\n" f"{format_shopping_context(shopping_snapshot)}\n\n"
f"{format_reminders_context(reminders_snapshot)}\n\n"
f"{format_weather_snapshot()}\n\n" f"{format_weather_snapshot()}\n\n"
f"{format_pomodoro_context(status)}\n\n" f"{format_pomodoro_context(status)}\n\n"
f"{format_projects_context(projects_snapshot)}" f"{format_projects_context(projects_snapshot)}"
+1
View File
@@ -45,6 +45,7 @@ class Settings(BaseSettings):
wger_base_url: str = "https://wger.de/api/v2" wger_base_url: str = "https://wger.de/api/v2"
openfoodfacts_base_url: str = "https://world.openfoodfacts.org" openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
fitness_reminders_enabled: bool = True fitness_reminders_enabled: bool = True
reminders_enabled: bool = True
openmeteo_base_url: str = "http://192.168.1.109:8085" openmeteo_base_url: str = "http://192.168.1.109:8085"
weather_lat: float = 59.9343 weather_lat: float = 59.9343
+19
View File
@@ -251,6 +251,25 @@ class ShoppingListItem(Base):
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items") shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255))
notes: Mapped[str] = mapped_column(Text, default="")
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence: Mapped[str] = mapped_column(String(16), default="none")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantState(Base): class AssistantState(Base):
__tablename__ = "assistant_state" __tablename__ = "assistant_state"
+5
View File
@@ -10,6 +10,7 @@ from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop from app.fitness.watcher import fitness_watcher_loop
from app.homelab.watcher import homelab_watcher_loop from app.homelab.watcher import homelab_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop from app.pomodoro.watcher import pomodoro_watcher_loop
from app.reminders.watcher import reminders_watcher_loop
@asynccontextmanager @asynccontextmanager
@@ -18,16 +19,20 @@ async def lifespan(_: FastAPI):
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop()) pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
fitness_task = asyncio.create_task(fitness_watcher_loop()) fitness_task = asyncio.create_task(fitness_watcher_loop())
homelab_task = asyncio.create_task(homelab_watcher_loop()) homelab_task = asyncio.create_task(homelab_watcher_loop())
reminders_task = asyncio.create_task(reminders_watcher_loop())
yield yield
pomodoro_task.cancel() pomodoro_task.cancel()
fitness_task.cancel() fitness_task.cancel()
homelab_task.cancel() homelab_task.cancel()
reminders_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await pomodoro_task await pomodoro_task
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await fitness_task await fitness_task
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await homelab_task await homelab_task
with suppress(asyncio.CancelledError):
await reminders_task
def create_app() -> FastAPI: def create_app() -> FastAPI:
+3
View File
@@ -0,0 +1,3 @@
from app.reminders.service import RemindersService
__all__ = ["RemindersService"]
+33
View File
@@ -0,0 +1,33 @@
from typing import Any
from sqlalchemy.orm import Session
from app.reminders.service import RemindersService
MAX_IN_CONTEXT = 10
def get_reminders_snapshot(db: Session) -> dict[str, Any]:
return RemindersService(db).snapshot()
def format_reminders_context(snapshot: dict[str, Any]) -> str:
lines = ["[Напоминания]"]
upcoming = snapshot.get("upcoming") or []
tz = snapshot.get("timezone", "Europe/Moscow")
if not upcoming:
lines.append(
"Ближайших напоминаний нет. "
"create_reminder для «напомни через 15 минут», «завтра утром», точной даты."
)
return "\n".join(lines)
lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.")
for item in upcoming[:MAX_IN_CONTEXT]:
rec = item.get("recurrence", "none")
rec_label = f" · повтор: {rec}" if rec and rec != "none" else ""
lines.append(
f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}"
)
return "\n".join(lines)
+60
View File
@@ -0,0 +1,60 @@
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.chat.notice_inbox import post_notice_to_latest_chat
from app.db.models import Reminder
from app.reminders.notify import bump_notify_seq
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def check_due_reminders(db: Session) -> int:
now = _utcnow()
stmt = (
select(Reminder)
.where(
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
Reminder.due_at <= now,
)
.order_by(Reminder.due_at.asc())
)
rows = list(db.scalars(stmt).all())
fired = 0
for row in rows:
if row.last_fired_at and row.last_fired_at >= row.due_at:
continue
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
if row.notes:
notice += f"\n{row.notes}"
post_notice_to_latest_chat(notice)
row.last_fired_at = now
if row.recurrence == RECURRENCE_NONE:
row.completed_at = now
row.enabled = False
else:
row.due_at = _advance_due(row.due_at, row.recurrence)
row.last_fired_at = None
row.updated_at = now
fired += 1
if fired:
db.commit()
bump_notify_seq(db)
logger.info("Reminders fired: %d", fired)
return fired
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy.orm import Session
from app.homelab.state import get_state, set_state
NOTIFY_SEQ_KEY = "reminders_notify_seq"
def get_notify_seq(db: Session) -> int:
raw = get_state(db, NOTIFY_SEQ_KEY)
try:
return int(raw or 0)
except ValueError:
return 0
def bump_notify_seq(db: Session) -> int:
seq = get_notify_seq(db) + 1
set_state(db, NOTIFY_SEQ_KEY, str(seq))
return seq
+226
View File
@@ -0,0 +1,226 @@
import calendar
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import Reminder
from app.homelab.context import resolve_timezone
from app.reminders.notify import bump_notify_seq, get_notify_seq
RECURRENCE_NONE = "none"
RECURRENCE_DAILY = "daily"
RECURRENCE_WEEKLY = "weekly"
RECURRENCE_MONTHLY = "monthly"
VALID_RECURRENCE = frozenset({RECURRENCE_NONE, RECURRENCE_DAILY, RECURRENCE_WEEKLY, RECURRENCE_MONTHLY})
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _parse_due_at(raw: str, tz_name: str) -> datetime:
clean = raw.strip()
if not clean:
raise ValueError("due_at не может быть пустым")
try:
dt = datetime.fromisoformat(clean.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"Неверный формат даты: {raw}") from exc
if dt.tzinfo is None:
try:
dt = dt.replace(tzinfo=ZoneInfo(tz_name))
except Exception:
dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow"))
return dt.astimezone(timezone.utc)
def _advance_due(due_at: datetime, recurrence: str) -> datetime:
if recurrence == RECURRENCE_DAILY:
return due_at + timedelta(days=1)
if recurrence == RECURRENCE_WEEKLY:
return due_at + timedelta(weeks=1)
if recurrence == RECURRENCE_MONTHLY:
month = due_at.month + 1
year = due_at.year
if month > 12:
month = 1
year += 1
day = min(due_at.day, calendar.monthrange(year, month)[1])
return due_at.replace(year=year, month=month, day=day)
return due_at
def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str:
try:
local = dt.astimezone(ZoneInfo(tz_name))
except Exception:
local = dt.astimezone(ZoneInfo("Europe/Moscow"))
if all_day:
return local.strftime("%Y-%m-%d")
return local.strftime("%Y-%m-%d %H:%M")
class RemindersService:
def __init__(self, db: Session):
self.db = db
def _tz(self) -> str:
return resolve_timezone(self.db)
def _to_dict(self, row: Reminder) -> dict[str, Any]:
tz = row.timezone or self._tz()
return {
"id": row.id,
"title": row.title,
"notes": row.notes,
"due_at": row.due_at.isoformat(),
"due_at_local": _format_local(row.due_at, tz, all_day=row.all_day),
"all_day": row.all_day,
"recurrence": row.recurrence,
"enabled": row.enabled,
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
"timezone": tz,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def snapshot(self) -> dict[str, Any]:
upcoming = self.list_upcoming(limit=12)
return {
"notify_seq": get_notify_seq(self.db),
"upcoming": upcoming,
"upcoming_count": len(upcoming),
"timezone": self._tz(),
}
def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]:
stmt = (
select(Reminder)
.where(
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
)
.order_by(Reminder.due_at.asc())
.limit(limit)
)
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
def list_in_range(
self,
*,
date_from: datetime,
date_to: datetime,
) -> list[dict[str, Any]]:
stmt = (
select(Reminder)
.where(
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
Reminder.due_at >= date_from,
Reminder.due_at < date_to,
)
.order_by(Reminder.due_at.asc())
)
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
def get(self, reminder_id: int) -> dict[str, Any] | None:
row = self.db.get(Reminder, reminder_id)
return self._to_dict(row) if row else None
def create(
self,
*,
title: str,
due_at: str,
notes: str = "",
all_day: bool = False,
recurrence: str = RECURRENCE_NONE,
) -> dict[str, Any]:
clean_title = title.strip()
if not clean_title:
raise ValueError("Название напоминания не может быть пустым")
rec = (recurrence or RECURRENCE_NONE).strip().lower()
if rec not in VALID_RECURRENCE:
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
tz = self._tz()
due = _parse_due_at(due_at, tz)
row = Reminder(
title=clean_title,
notes=notes.strip(),
due_at=due,
all_day=all_day,
recurrence=rec,
timezone=tz,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row), "created": True}
def update(
self,
reminder_id: int,
*,
title: str | None = None,
due_at: str | None = None,
notes: str | None = None,
all_day: bool | None = None,
recurrence: str | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row:
raise ValueError("Напоминание не найдено")
if title is not None:
clean = title.strip()
if not clean:
raise ValueError("Название не может быть пустым")
row.title = clean
if notes is not None:
row.notes = notes.strip()
if due_at is not None:
row.due_at = _parse_due_at(due_at, row.timezone or self._tz())
row.last_fired_at = None
if all_day is not None:
row.all_day = all_day
if recurrence is not None:
rec = recurrence.strip().lower()
if rec not in VALID_RECURRENCE:
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
row.recurrence = rec
if enabled is not None:
row.enabled = enabled
row.updated_at = _utcnow()
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row)}
def delete(self, reminder_id: int) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row:
raise ValueError("Напоминание не найдено")
title = row.title
self.db.delete(row)
self.db.commit()
bump_notify_seq(self.db)
return {"ok": True, "deleted_id": reminder_id, "title": title}
def complete(self, reminder_id: int) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row:
raise ValueError("Напоминание не найдено")
now = _utcnow()
row.completed_at = now
row.enabled = False
row.updated_at = now
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row)}
+31
View File
@@ -0,0 +1,31 @@
import asyncio
import logging
from app.config import get_settings
from app.db.base import SessionLocal
from app.reminders.fire import check_due_reminders
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 30
async def reminders_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
if not get_settings().reminders_enabled:
continue
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Reminders watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
check_due_reminders(db)
finally:
db.close()
+109
View File
@@ -13,6 +13,7 @@ from app.integrations.wger import WgerClient
from app.memory.service import MemoryService from app.memory.service import MemoryService
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
from app.projects.service import ProjectService from app.projects.service import ProjectService
from app.reminders.service import RemindersService
from app.shopping.service import ShoppingService from app.shopping.service import ShoppingService
TOOL_DEFINITIONS: list[dict[str, Any]] = [ TOOL_DEFINITIONS: list[dict[str, Any]] = [
@@ -585,6 +586,89 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "list_reminders",
"description": "Список активных напоминаний. «Что напомнил», «мои напоминания».",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "create_reminder",
"description": (
"Создать напоминание. due_at — ISO datetime в часовом поясе пользователя "
"(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "О чём напомнить"},
"due_at": {"type": "string", "description": "ISO datetime"},
"notes": {"type": "string"},
"all_day": {"type": "boolean"},
"recurrence": {
"type": "string",
"enum": ["none", "daily", "weekly", "monthly"],
"description": "Повтор",
},
},
"required": ["title", "due_at"],
},
},
},
{
"type": "function",
"function": {
"name": "update_reminder",
"description": "Изменить напоминание по id.",
"parameters": {
"type": "object",
"properties": {
"reminder_id": {"type": "integer"},
"title": {"type": "string"},
"due_at": {"type": "string"},
"notes": {"type": "string"},
"all_day": {"type": "boolean"},
"recurrence": {"type": "string", "enum": ["none", "daily", "weekly", "monthly"]},
"enabled": {"type": "boolean"},
},
"required": ["reminder_id"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_reminder",
"description": "Удалить напоминание по id.",
"parameters": {
"type": "object",
"properties": {"reminder_id": {"type": "integer"}},
"required": ["reminder_id"],
},
},
},
{
"type": "function",
"function": {
"name": "complete_reminder",
"description": "Отметить напоминание выполненным (снять с календаря).",
"parameters": {
"type": "object",
"properties": {"reminder_id": {"type": "integer"}},
"required": ["reminder_id"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -618,6 +702,7 @@ async def execute_tool(
memory = MemoryService(db) memory = MemoryService(db)
fitness = FitnessService(db) fitness = FitnessService(db)
shopping = ShoppingService(db) shopping = ShoppingService(db)
reminders = RemindersService(db)
try: try:
if name == "get_pomodoro_status": if name == "get_pomodoro_status":
@@ -792,6 +877,30 @@ async def execute_tool(
result = shopping.remove_item(int(arguments["item_id"])) result = shopping.remove_item(int(arguments["item_id"]))
elif name == "delete_shopping_list": elif name == "delete_shopping_list":
result = shopping.delete_list(int(arguments["list_id"])) result = shopping.delete_list(int(arguments["list_id"]))
elif name == "list_reminders":
result = reminders.list_upcoming(limit=int(arguments.get("limit") or 20))
elif name == "create_reminder":
result = reminders.create(
title=arguments.get("title", ""),
due_at=arguments.get("due_at", ""),
notes=arguments.get("notes", ""),
all_day=bool(arguments.get("all_day", False)),
recurrence=arguments.get("recurrence", "none"),
)
elif name == "update_reminder":
result = reminders.update(
int(arguments["reminder_id"]),
title=arguments.get("title"),
due_at=arguments.get("due_at"),
notes=arguments.get("notes"),
all_day=arguments.get("all_day"),
recurrence=arguments.get("recurrence"),
enabled=arguments.get("enabled"),
)
elif name == "delete_reminder":
result = reminders.delete(int(arguments["reminder_id"]))
elif name == "complete_reminder":
result = reminders.complete(int(arguments["reminder_id"]))
else: else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
+3
View File
@@ -5,6 +5,7 @@ import { useVisualViewportHeight } from "./hooks/useVisualViewport";
import Character from "./pages/Character"; import Character from "./pages/Character";
import Chat from "./pages/Chat"; import Chat from "./pages/Chat";
import Fitness from "./pages/Fitness"; import Fitness from "./pages/Fitness";
import Reminders from "./pages/Reminders";
import Shopping from "./pages/Shopping"; import Shopping from "./pages/Shopping";
import Memory from "./pages/Memory"; import Memory from "./pages/Memory";
import Pomodoro from "./pages/Pomodoro"; import Pomodoro from "./pages/Pomodoro";
@@ -27,6 +28,7 @@ export default function App() {
<NavLink to="/memory">Память</NavLink> <NavLink to="/memory">Память</NavLink>
<NavLink to="/fitness">Фитнес</NavLink> <NavLink to="/fitness">Фитнес</NavLink>
<NavLink to="/shopping">Покупки</NavLink> <NavLink to="/shopping">Покупки</NavLink>
<NavLink to="/reminders">Календарь</NavLink>
<PomodoroWidget compact /> <PomodoroWidget compact />
</nav> </nav>
</header> </header>
@@ -38,6 +40,7 @@ export default function App() {
<Route path="/memory" element={<Memory />} /> <Route path="/memory" element={<Memory />} />
<Route path="/fitness" element={<Fitness />} /> <Route path="/fitness" element={<Fitness />} />
<Route path="/shopping" element={<Shopping />} /> <Route path="/shopping" element={<Shopping />} />
<Route path="/reminders" element={<Reminders />} />
</Routes> </Routes>
</main> </main>
</div> </div>
+63
View File
@@ -422,6 +422,33 @@ export const api = {
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, { request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, {
method: "POST", method: "POST",
}), }),
getRemindersSnapshot: () => request<RemindersSnapshot>("/api/v1/reminders"),
getRemindersCalendar: (year: number, month: number) =>
request<RemindersCalendar>(`/api/v1/reminders/calendar?year=${year}&month=${month}`),
createReminder: (payload: ReminderCreatePayload) =>
request<{ ok: boolean; reminder: Reminder }>("/api/v1/reminders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
updateReminder: (id: number, payload: Partial<ReminderCreatePayload> & { enabled?: boolean }) =>
request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
deleteReminder: (id: number) =>
request<{ ok: boolean }>(`/api/v1/reminders/${id}`, { method: "DELETE" }),
completeReminder: (id: number) =>
request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}/complete`, {
method: "POST",
}),
}; };
export interface ShoppingListItem { export interface ShoppingListItem {
@@ -449,3 +476,39 @@ export interface ShoppingSnapshot {
total_items: number; total_items: number;
unchecked_items: number; unchecked_items: number;
} }
export interface Reminder {
id: number;
title: string;
notes: string;
due_at: string;
due_at_local: string;
all_day: boolean;
recurrence: string;
enabled: boolean;
completed_at: string | null;
timezone: string;
created_at: string | null;
}
export interface RemindersSnapshot {
notify_seq: number;
upcoming: Reminder[];
upcoming_count: number;
timezone: string;
}
export interface RemindersCalendar {
year: number;
month: number;
timezone: string;
reminders: Reminder[];
}
export interface ReminderCreatePayload {
title: string;
due_at: string;
notes?: string;
all_day?: boolean;
recurrence?: string;
}
+38
View File
@@ -22,6 +22,7 @@ function noticeLabel(content: string): string {
if (content.startsWith("🎨")) return "картинка"; if (content.startsWith("🎨")) return "картинка";
if (content.startsWith("⚠️")) return "сервер"; if (content.startsWith("⚠️")) return "сервер";
if (content.startsWith("🛒")) return "покупки"; if (content.startsWith("🛒")) return "покупки";
if (content.startsWith("📅")) return "напоминание";
return "система"; return "система";
} }
@@ -54,6 +55,8 @@ export default function Chat() {
const scrollRafRef = useRef<number | null>(null); const scrollRafRef = useRef<number | null>(null);
const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro(); const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
const [lastNotifySeq, setLastNotifySeq] = useState(0); const [lastNotifySeq, setLastNotifySeq] = useState(0);
const lastReminderNotifySeq = useRef(0);
const remindersNotifyReady = useRef(false);
const pendingHistoryReload = useRef(false); const pendingHistoryReload = useRef(false);
const loadSessions = async () => { const loadSessions = async () => {
@@ -134,6 +137,41 @@ export default function Chat() {
} }
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro, loading]); }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro, loading]);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const data = await api.getRemindersSnapshot();
if (cancelled) return;
if (!remindersNotifyReady.current) {
remindersNotifyReady.current = true;
lastReminderNotifySeq.current = data.notify_seq;
return;
}
if (data.notify_seq > lastReminderNotifySeq.current) {
lastReminderNotifySeq.current = data.notify_seq;
if (activeId) {
if (loading) {
pendingHistoryReload.current = true;
} else {
loadMessages(activeId).catch(console.error);
}
}
}
} catch {
// ignore polling errors
}
};
poll().catch(console.error);
const id = setInterval(() => poll().catch(console.error), 60000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [activeId, loading]);
const handleNewChat = async () => { const handleNewChat = async () => {
const session = await api.createSession(); const session = await api.createSession();
await loadSessions(); await loadSessions();
+193
View File
@@ -0,0 +1,193 @@
.reminders-page {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
height: 100%;
min-height: 0;
overflow: auto;
padding: 1rem;
}
.reminders-calendar-card,
.reminders-sidebar {
background: #12151c;
border: 1px solid #2f3748;
border-radius: 12px;
padding: 1rem;
overflow: auto;
}
.reminders-calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.reminders-calendar-header h2 {
margin: 0;
font-size: 1.2rem;
}
.reminders-calendar-header button {
width: 2rem;
height: 2rem;
border-radius: 8px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
cursor: pointer;
}
.reminders-tz {
margin: 0 0 0.75rem;
color: #8b95a5;
font-size: 0.85rem;
}
.reminders-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
margin-bottom: 0.35rem;
color: #8b95a5;
font-size: 0.8rem;
text-align: center;
}
.reminders-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.35rem;
}
.reminders-day {
position: relative;
min-height: 3.2rem;
border: 1px solid #2f3748;
border-radius: 8px;
background: #1a1f2b;
color: inherit;
cursor: pointer;
padding: 0.35rem;
}
.reminders-day-empty {
border: none;
background: transparent;
cursor: default;
}
.reminders-day.today {
border-color: #4a7cff;
}
.reminders-day.selected {
background: #1c2740;
border-color: #6b93ff;
}
.reminders-day-num {
font-size: 0.9rem;
}
.reminders-day-badge {
position: absolute;
right: 0.35rem;
bottom: 0.35rem;
min-width: 1.1rem;
height: 1.1rem;
border-radius: 999px;
background: #4a7cff;
color: #fff;
font-size: 0.7rem;
line-height: 1.1rem;
text-align: center;
}
.reminders-day-panel {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #2f3748;
}
.reminders-day-panel ul,
.reminders-upcoming {
list-style: none;
margin: 0;
padding: 0;
}
.reminders-day-panel li,
.reminders-upcoming li {
padding: 0.65rem 0;
border-bottom: 1px solid #2a3140;
}
.reminders-upcoming-title {
font-weight: 600;
}
.reminders-upcoming-meta {
color: #8b95a5;
font-size: 0.85rem;
margin-top: 0.15rem;
}
.reminders-item-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.4rem;
}
.reminders-item-actions button {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
cursor: pointer;
}
.reminders-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-bottom: 1.25rem;
}
.reminders-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #b8c0cc;
}
.reminders-form input,
.reminders-form select,
.reminders-form textarea {
border-radius: 8px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
padding: 0.5rem 0.65rem;
}
.reminders-muted {
color: #8b95a5;
font-size: 0.9rem;
}
.reminders-error {
color: #ff8f8f;
margin-top: 0.75rem;
}
@media (max-width: 900px) {
.reminders-page {
grid-template-columns: 1fr;
}
}
+300
View File
@@ -0,0 +1,300 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { api, Reminder } from "../api/client";
import "./Reminders.css";
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const MONTHS = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
function pad2(n: number) {
return String(n).padStart(2, "0");
}
function dateKey(year: number, month: number, day: number) {
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function defaultDatetimeLocal() {
const d = new Date();
d.setMinutes(d.getMinutes() + 30);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
function toIsoDueAt(localValue: string) {
const d = new Date(localValue);
const offset = -d.getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const abs = Math.abs(offset);
const hh = pad2(Math.floor(abs / 60));
const mm = pad2(abs % 60);
return `${localValue}:00${sign}${hh}:${mm}`;
}
export default function Reminders() {
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1);
const [reminders, setReminders] = useState<Reminder[]>([]);
const [upcoming, setUpcoming] = useState<Reminder[]>([]);
const [timezone, setTimezone] = useState("");
const [selectedDay, setSelectedDay] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const [dueLocal, setDueLocal] = useState(defaultDatetimeLocal);
const [notes, setNotes] = useState("");
const [recurrence, setRecurrence] = useState("none");
const load = useCallback(async () => {
setLoading(true);
try {
const [calendar, snapshot] = await Promise.all([
api.getRemindersCalendar(year, month),
api.getRemindersSnapshot(),
]);
setReminders(calendar.reminders);
setTimezone(calendar.timezone || snapshot.timezone);
setUpcoming(snapshot.upcoming);
setMessage("");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setLoading(false);
}
}, [year, month]);
useEffect(() => {
load().catch(console.error);
}, [load]);
const byDay = useMemo(() => {
const map = new Map<string, Reminder[]>();
for (const item of reminders) {
const key = item.due_at_local.slice(0, 10);
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return map;
}, [reminders]);
const monthCells = useMemo(() => {
const first = new Date(year, month - 1, 1);
const daysInMonth = new Date(year, month, 0).getDate();
const startPad = (first.getDay() + 6) % 7;
const cells: Array<{ day: number | null; key: string | null }> = [];
for (let i = 0; i < startPad; i++) cells.push({ day: null, key: null });
for (let day = 1; day <= daysInMonth; day++) {
cells.push({ day, key: dateKey(year, month, day) });
}
return cells;
}, [year, month]);
const selectedReminders = selectedDay ? byDay.get(selectedDay) ?? [] : [];
const shiftMonth = (delta: number) => {
let m = month + delta;
let y = year;
if (m < 1) {
m = 12;
y -= 1;
} else if (m > 12) {
m = 1;
y += 1;
}
setMonth(m);
setYear(y);
setSelectedDay(null);
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
try {
await api.createReminder({
title: title.trim(),
due_at: toIsoDueAt(dueLocal),
notes: notes.trim(),
recurrence,
});
setTitle("");
setNotes("");
setRecurrence("none");
setDueLocal(defaultDatetimeLocal());
await load();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка");
}
};
const handleComplete = async (id: number) => {
await api.completeReminder(id);
await load();
};
const handleDelete = async (id: number) => {
await api.deleteReminder(id);
await load();
};
const todayKey = dateKey(now.getFullYear(), now.getMonth() + 1, now.getDate());
return (
<div className="reminders-page">
<section className="reminders-calendar-card">
<header className="reminders-calendar-header">
<button type="button" onClick={() => shiftMonth(-1)} aria-label="Предыдущий месяц">
</button>
<h2>
{MONTHS[month - 1]} {year}
</h2>
<button type="button" onClick={() => shiftMonth(1)} aria-label="Следующий месяц">
</button>
</header>
{timezone && <p className="reminders-tz">Часовой пояс: {timezone}</p>}
<div className="reminders-weekdays">
{WEEKDAYS.map((d) => (
<span key={d}>{d}</span>
))}
</div>
<div className="reminders-grid">
{monthCells.map((cell, idx) => {
if (!cell.day || !cell.key) {
return <div key={`empty-${idx}`} className="reminders-day reminders-day-empty" />;
}
const count = byDay.get(cell.key)?.length ?? 0;
const isToday = cell.key === todayKey;
const isSelected = cell.key === selectedDay;
return (
<button
key={cell.key}
type="button"
className={`reminders-day${isToday ? " today" : ""}${isSelected ? " selected" : ""}`}
onClick={() => setSelectedDay(cell.key)}
>
<span className="reminders-day-num">{cell.day}</span>
{count > 0 && <span className="reminders-day-badge">{count}</span>}
</button>
);
})}
</div>
{selectedDay && (
<div className="reminders-day-panel">
<h3>{selectedDay}</h3>
{selectedReminders.length === 0 ? (
<p className="reminders-muted">Нет напоминаний</p>
) : (
<ul>
{selectedReminders.map((r) => (
<li key={r.id}>
<strong>{r.title}</strong>
<span>{r.all_day ? "весь день" : r.due_at_local.slice(11)}</span>
{r.notes && <p>{r.notes}</p>}
<div className="reminders-item-actions">
<button type="button" onClick={() => handleComplete(r.id)}>
Готово
</button>
<button type="button" onClick={() => handleDelete(r.id)}>
Удалить
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
</section>
<aside className="reminders-sidebar">
<h3>Новое напоминание</h3>
<form className="reminders-form" onSubmit={handleCreate}>
<label>
Текст
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Поесть, одеть куртку…"
required
/>
</label>
<label>
Когда
<input
type="datetime-local"
value={dueLocal}
onChange={(e) => setDueLocal(e.target.value)}
required
/>
</label>
<label>
Повтор
<select value={recurrence} onChange={(e) => setRecurrence(e.target.value)}>
<option value="none">Один раз</option>
<option value="daily">Каждый день</option>
<option value="weekly">Каждую неделю</option>
<option value="monthly">Каждый месяц</option>
</select>
</label>
<label>
Заметка
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder="Необязательно"
/>
</label>
<button type="submit" className="primary-btn">
Добавить
</button>
</form>
<h3>Ближайшие</h3>
{loading && <p className="reminders-muted">Загрузка</p>}
<ul className="reminders-upcoming">
{upcoming.length === 0 && !loading && (
<li className="reminders-muted">Пока пусто попроси в чате: «напомни через 15 минут»</li>
)}
{upcoming.map((r) => (
<li key={r.id}>
<div className="reminders-upcoming-title">{r.title}</div>
<div className="reminders-upcoming-meta">
{r.due_at_local}
{r.recurrence !== "none" ? ` · ${r.recurrence}` : ""}
</div>
<div className="reminders-item-actions">
<button type="button" onClick={() => handleComplete(r.id)}>
Готово
</button>
<button type="button" onClick={() => handleDelete(r.id)}>
Удалить
</button>
</div>
</li>
))}
</ul>
{message && <p className="reminders-error">{message}</p>}
</aside>
</div>
);
}
+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/context/pomodorocontext.tsx","./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"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/context/pomodorocontext.tsx","./src/hooks/usevisualviewport.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/fitness.tsx","./src/pages/memory.tsx","./src/pages/pomodoro.tsx","./src/pages/reminders.tsx","./src/pages/shopping.tsx","./src/utils/charactercard.ts","./src/utils/pomodoro.ts","./src/utils/pomodorocountdown.ts","./src/utils/time.ts"],"version":"5.9.3"}