added reminder
This commit is contained in:
@@ -30,6 +30,7 @@ MEMORY_AUTO_EXTRACT=true
|
||||
WGER_BASE_URL=https://wger.de/api/v2
|
||||
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
|
||||
FITNESS_REMINDERS_ENABLED=true
|
||||
REMINDERS_ENABLED=true
|
||||
|
||||
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
|
||||
TAIGA_BASE_URL=http://host.docker.internal:9000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.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(fitness.router, tags=["fitness"])
|
||||
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(media.router, tags=["media"])
|
||||
|
||||
@@ -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
|
||||
@@ -25,6 +25,9 @@ TOOLS_INSTRUCTIONS = """
|
||||
- Картинки: 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.
|
||||
- «Добавь в список покупок» → 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()
|
||||
|
||||
DEFAULT_CARD: dict[str, Any] = {
|
||||
|
||||
@@ -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({
|
||||
"list_shopping_lists",
|
||||
"create_shopping_list",
|
||||
@@ -88,6 +96,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||
"get_weather",
|
||||
"get_morning_briefing",
|
||||
"list_shopping_lists",
|
||||
"list_reminders",
|
||||
})
|
||||
|
||||
|
||||
@@ -109,6 +118,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
prefix = "💪"
|
||||
elif tool_name in SHOPPING_TOOL_NAMES:
|
||||
prefix = "🛒"
|
||||
elif tool_name in REMINDER_TOOL_NAMES:
|
||||
prefix = "📅"
|
||||
else:
|
||||
prefix = "📋"
|
||||
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"):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.memory.context import (
|
||||
)
|
||||
from app.memory.extract import extract_after_turn
|
||||
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.db.models import ChatSession, Message
|
||||
from app.llm.client import LLMClient
|
||||
@@ -99,6 +100,7 @@ class ChatService:
|
||||
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
||||
fitness_snapshot = get_fitness_snapshot(self.db)
|
||||
shopping_snapshot = get_shopping_snapshot(self.db)
|
||||
reminders_snapshot = get_reminders_snapshot(self.db)
|
||||
projects_snapshot = get_projects_snapshot(self.db)
|
||||
return (
|
||||
f"{self.character.get_system_prompt()}\n\n"
|
||||
@@ -106,6 +108,7 @@ class ChatService:
|
||||
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||
f"{format_fitness_context(fitness_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_pomodoro_context(status)}\n\n"
|
||||
f"{format_projects_context(projects_snapshot)}"
|
||||
|
||||
@@ -45,6 +45,7 @@ class Settings(BaseSettings):
|
||||
wger_base_url: str = "https://wger.de/api/v2"
|
||||
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
|
||||
fitness_reminders_enabled: bool = True
|
||||
reminders_enabled: bool = True
|
||||
|
||||
openmeteo_base_url: str = "http://192.168.1.109:8085"
|
||||
weather_lat: float = 59.9343
|
||||
|
||||
@@ -251,6 +251,25 @@ class ShoppingListItem(Base):
|
||||
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):
|
||||
__tablename__ = "assistant_state"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.db.base import init_db
|
||||
from app.fitness.watcher import fitness_watcher_loop
|
||||
from app.homelab.watcher import homelab_watcher_loop
|
||||
from app.pomodoro.watcher import pomodoro_watcher_loop
|
||||
from app.reminders.watcher import reminders_watcher_loop
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -18,16 +19,20 @@ async def lifespan(_: FastAPI):
|
||||
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
||||
homelab_task = asyncio.create_task(homelab_watcher_loop())
|
||||
reminders_task = asyncio.create_task(reminders_watcher_loop())
|
||||
yield
|
||||
pomodoro_task.cancel()
|
||||
fitness_task.cancel()
|
||||
homelab_task.cancel()
|
||||
reminders_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await pomodoro_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await fitness_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await homelab_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await reminders_task
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.reminders.service import RemindersService
|
||||
|
||||
__all__ = ["RemindersService"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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()
|
||||
@@ -13,6 +13,7 @@ from app.integrations.wger import WgerClient
|
||||
from app.memory.service import MemoryService
|
||||
from app.pomodoro.service import PomodoroService
|
||||
from app.projects.service import ProjectService
|
||||
from app.reminders.service import RemindersService
|
||||
from app.shopping.service import ShoppingService
|
||||
|
||||
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",
|
||||
"function": {
|
||||
@@ -618,6 +702,7 @@ async def execute_tool(
|
||||
memory = MemoryService(db)
|
||||
fitness = FitnessService(db)
|
||||
shopping = ShoppingService(db)
|
||||
reminders = RemindersService(db)
|
||||
|
||||
try:
|
||||
if name == "get_pomodoro_status":
|
||||
@@ -792,6 +877,30 @@ async def execute_tool(
|
||||
result = shopping.remove_item(int(arguments["item_id"]))
|
||||
elif name == "delete_shopping_list":
|
||||
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:
|
||||
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useVisualViewportHeight } from "./hooks/useVisualViewport";
|
||||
import Character from "./pages/Character";
|
||||
import Chat from "./pages/Chat";
|
||||
import Fitness from "./pages/Fitness";
|
||||
import Reminders from "./pages/Reminders";
|
||||
import Shopping from "./pages/Shopping";
|
||||
import Memory from "./pages/Memory";
|
||||
import Pomodoro from "./pages/Pomodoro";
|
||||
@@ -27,6 +28,7 @@ export default function App() {
|
||||
<NavLink to="/memory">Память</NavLink>
|
||||
<NavLink to="/fitness">Фитнес</NavLink>
|
||||
<NavLink to="/shopping">Покупки</NavLink>
|
||||
<NavLink to="/reminders">Календарь</NavLink>
|
||||
<PomodoroWidget compact />
|
||||
</nav>
|
||||
</header>
|
||||
@@ -38,6 +40,7 @@ export default function App() {
|
||||
<Route path="/memory" element={<Memory />} />
|
||||
<Route path="/fitness" element={<Fitness />} />
|
||||
<Route path="/shopping" element={<Shopping />} />
|
||||
<Route path="/reminders" element={<Reminders />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -422,6 +422,33 @@ export const api = {
|
||||
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, {
|
||||
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 {
|
||||
@@ -449,3 +476,39 @@ export interface ShoppingSnapshot {
|
||||
total_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;
|
||||
}
|
||||
|
||||
@@ -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 "напоминание";
|
||||
return "система";
|
||||
}
|
||||
|
||||
@@ -54,6 +55,8 @@ export default function Chat() {
|
||||
const scrollRafRef = useRef<number | null>(null);
|
||||
const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
|
||||
const [lastNotifySeq, setLastNotifySeq] = useState(0);
|
||||
const lastReminderNotifySeq = useRef(0);
|
||||
const remindersNotifyReady = useRef(false);
|
||||
const pendingHistoryReload = useRef(false);
|
||||
|
||||
const loadSessions = async () => {
|
||||
@@ -134,6 +137,41 @@ export default function Chat() {
|
||||
}
|
||||
}, [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 session = await api.createSession();
|
||||
await loadSessions();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user