diff --git a/.gitignore b/.gitignore index d020032..9e5a95f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ # Node node_modules/ dist/ +# Сборочный вывод vite-plugin-pwa в режиме разработки (VITE_PWA_DEV=1) +dev-dist/ *.local *.local.html diff --git a/backend/alembic/versions/d2e3f4a5b6c7_push_deliveries.py b/backend/alembic/versions/d2e3f4a5b6c7_push_deliveries.py new file mode 100644 index 0000000..1aa354e --- /dev/null +++ b/backend/alembic/versions/d2e3f4a5b6c7_push_deliveries.py @@ -0,0 +1,53 @@ +r"""Напоминания: журнал отправленного (ТЗ 3.21, 0.89). + +Таблица push_deliveries — память планировщика о том, что уже отправлено. Тик +каждые 15 минут, а правила дневные, поэтому без журнала одна и та же просрочка +приходила бы сотню раз. Уникальность (user_id, kind, ref_key) держит база, а не +память процесса: перезапуск API не рассылает отправленное заново. + +Вставка идёт до отправки, поэтому возможен пропуск (упали между вставкой и +отправкой), но не дубль — для напоминания это правильный размен. + +Revision ID: d2e3f4a5b6c7 +Revises: c1d2e3f4a5b6 +Create Date: 2026-10-10 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "d2e3f4a5b6c7" +down_revision: str | Sequence[str] | None = "c1d2e3f4a5b6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "push_deliveries", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.String(64), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("kind", sa.String(40), nullable=False), + sa.Column("ref_key", sa.String(120), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.UniqueConstraint("user_id", "kind", "ref_key", name="uq_push_deliveries_ref"), + ) + op.create_index("ix_push_deliveries_user_id", "push_deliveries", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_push_deliveries_user_id", table_name="push_deliveries") + op.drop_table("push_deliveries") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index f17f754..d8d4f1a 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -13,13 +13,17 @@ from app.models import AppSetting from app.realtime import publish from app.schemas import CURRENCIES, LANGUAGES, AppSettingsOut, AppSettingsUpdate +from app.services import reminders router = APIRouter(prefix="/api/settings", tags=["settings"]) CURRENCY_KEY = "currency" LANGUAGE_KEY = "language" +# Тихие часы напоминаний (0.89): окно «HH:MM-HH:MM», пусто — не заданы +QUIET_HOURS_KEY = "quiet_hours" DEFAULT_CURRENCY = "UAH" DEFAULT_LANGUAGE = "" # '' = нет переопределения, следуем SSO locale +DEFAULT_QUIET_HOURS = "" def _read_value(db: DbDep, user_id: str, key: str, valid: tuple[str, ...], default: str) -> str: @@ -38,11 +42,26 @@ db.flush() +def _read_quiet_hours(db: DbDep, user_id: str) -> str: + """Тихое окно наружу: только то, что разбирает планировщик. + + Значение — пользовательская настройка, и в базе может оказаться что угодно + (правили руками). Неразбираемое отдаём как «не задано»: тем же правилом + руководствуется и планировщик, и расхождение здесь означало бы, что интерфейс + показывает одно, а напоминания молчат по другому. + """ + row = cast(AppSetting | None, db.get(AppSetting, (user_id, QUIET_HOURS_KEY))) + if row is None or reminders.parse_quiet_hours(row.value) is None: + return DEFAULT_QUIET_HOURS + return row.value + + @router.get("", response_model=AppSettingsOut) async def get_settings(db: DbDep, user_id: UserIdDep) -> AppSettingsOut: return AppSettingsOut( currency=_read_value(db, user_id, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), language=_read_value(db, user_id, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), + quiet_hours=_read_quiet_hours(db, user_id), ) @@ -55,10 +74,13 @@ _upsert(db, user_id, CURRENCY_KEY, schema.currency) if schema.language is not None: _upsert(db, user_id, LANGUAGE_KEY, schema.language) + if schema.quiet_hours is not None: + _upsert(db, user_id, QUIET_HOURS_KEY, schema.quiet_hours) db.flush() db.commit() publish(user_id, "settings.changed") return AppSettingsOut( currency=_read_value(db, user_id, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), language=_read_value(db, user_id, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), + quiet_hours=_read_quiet_hours(db, user_id), ) diff --git a/backend/app/main.py b/backend/app/main.py index cfa8e49..b4c0efe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,9 @@ """Точка входа FastAPI: health + OAuth-флоу gnexus-gauth + защищённое /api + /mcp.""" +import asyncio import hashlib from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -24,14 +25,27 @@ from app.config import get_settings from app.db import get_session_factory from app.models import McpToken, User +from app.services import scheduler @asynccontextmanager async def _lifespan(app: FastAPI) -> AsyncIterator[None]: """Менеджер сессий MCP требует запуска: mounted ASGI-приложение - собственный lifespan не получает.""" + собственный lifespan не получает. + + Здесь же живёт планировщик напоминаний (ТЗ 3.21, 0.89). Задача, а не поток: + итерация уходит в `asyncio.to_thread`, потому что внутри синхронные SQLAlchemy + и `requests`, а сам цикл должен оставаться отменяемым — иначе он переживёт + приложение (то же правило, что и у SSE: процесс не должен оставлять сирот). + """ async with mcp_server.mcp.session_manager.run(): - yield + reminder_task = asyncio.create_task(scheduler.scheduler_loop()) + try: + yield + finally: + reminder_task.cancel() + with suppress(asyncio.CancelledError): + await reminder_task app = FastAPI(title="gntodo API", version="0.1.0", lifespan=_lifespan) diff --git a/backend/app/models.py b/backend/app/models.py index d4d2edb..74cc17f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -105,6 +105,35 @@ failure_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") +class PushDelivery(Base): + """Журнал отправленных напоминаний (ТЗ 3.21, 0.89) — защита от дублей. + + Напоминания дневные («сегодня день D»), а планировщик тикает каждые 15 минут: + без журнала одна и та же просрочка приходила бы сотню раз. Ключ дедупа — + (user_id, kind, ref_key), и уникальность держит база, а не память процесса: + перезапуск API (а он перезапускается на каждом деплое) не должен рассылать + отправленное заново. Вставка идёт **до** отправки: уведомление, потерянное + из-за падения между вставкой и отправкой, лучше повтора в шторке. + + Записи не чистятся — срок хранения ещё не выбран (8.14). + """ + + __tablename__ = "push_deliveries" + __table_args__ = ( + UniqueConstraint("user_id", "kind", "ref_key", name="uq_push_deliveries_ref"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + # Вид напоминания (task.due_today, summary, …) — тот же ключ, что у текстов + kind: Mapped[str] = mapped_column(String(40)) + # Что именно отправлено: id задачи и дата срока, день ритма, дата сводки + ref_key: Mapped[str] = mapped_column(String(120)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + class Task(Base): __tablename__ = "tasks" # Индексы под новые фильтры списка задач (ТЗ 3.20): доступные агенту, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0bac426..f2bff5f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -288,12 +288,17 @@ class AppSettingsOut(BaseModel): currency: str = "UAH" language: str = "" + # Тихие часы напоминаний «HH:MM-HH:MM»; пусто — не заданы (0.89) + quiet_hours: str = "" class AppSettingsUpdate(BaseModel): # Частичный PUT: None = поле не передано, не затираем currency: str | None = Field(None, pattern="^(UAH|USD|EUR|GBP|PLN)$") language: str | None = Field(None, pattern="^(|ru|en|uk)$") + quiet_hours: str | None = Field( + None, pattern=r"^(|([01]\d|2[0-3]):[0-5]\d-([01]\d|2[0-3]):[0-5]\d)$" + ) # --- Web Push (ТЗ 3.21, 0.88) --- diff --git a/backend/app/services/push_texts.py b/backend/app/services/push_texts.py index 1ce610d..0821b61 100644 --- a/backend/app/services/push_texts.py +++ b/backend/app/services/push_texts.py @@ -57,6 +57,56 @@ "«{title}» — подивіться, що зроблено, і прийміть або поверніть", ), }, + # Напоминания о сроке (0.89). Нестрогий период («в течение недели») сюда не + # попадает вовсе — у него нет дня отсчёта, см. services/reminders.py + "task.due_tomorrow": { + "ru": ("Срок завтра", "«{title}» — дедлайн завтра"), + "en": ("Due tomorrow", "“{title}” — the deadline is tomorrow"), + "uk": ("Термін завтра", "«{title}» — дедлайн завтра"), + }, + "task.due_today": { + "ru": ("Срок сегодня", "«{title}» — сегодня последний день"), + "en": ("Due today", "“{title}” — today is the last day"), + "uk": ("Термін сьогодні", "«{title}» — сьогодні останній день"), + }, + "task.overdue": { + "ru": ("Просрочено", "«{title}» — срок прошёл, задача всё ещё открыта"), + "en": ("Overdue", "“{title}” — the deadline has passed, the task is still open"), + "uk": ("Прострочено", "«{title}» — термін минув, завдання досі відкрите"), + }, + # Регулярная задача: напоминание по ритму, а не «появился экземпляр» — + # экземпляр рождается только после закрытия предыдущего + "task.rhythm": { + "ru": ("Пора", "«{title}» — по расписанию"), + "en": ("Time for it", "“{title}” — it is on the schedule"), + "uk": ("Час", "«{title}» — за розкладом"), + }, + # Сводка: тело собирается из частей ниже, пустые не показываем + "summary": { + "ru": ("Сводка на сегодня", "{parts}"), + "en": ("Today at a glance", "{parts}"), + "uk": ("Підсумок на сьогодні", "{parts}"), + }, +} + +# Части сводки: ключ → счётчик от `reminders.summary_counts`. Нулевые части +# выбрасываются, поэтому строка-шаблон описывает все три сразу +SUMMARY_PARTS: dict[str, dict[str, str]] = { + "ru": { + "overdue": "Просрочено: {overdue}", + "today": "Срок сегодня: {today}", + "review": "Ждут приёмки: {review}", + }, + "en": { + "overdue": "Overdue: {overdue}", + "today": "Due today: {today}", + "review": "Awaiting review: {review}", + }, + "uk": { + "overdue": "Прострочено: {overdue}", + "today": "Термін сьогодні: {today}", + "review": "Чекають на приймання: {review}", + }, } # Тексты уведомления живут секунды на экране блокировки — длинное тело в нём не @@ -87,6 +137,18 @@ return text[: limit - 1].rstrip() + "…" +def summary_body(language: str, counts: dict[str, int]) -> str: + """Тело сводки: только непустые части, через « · ». + + Отдельная функция, а не `render`: сводка — не «текст с подстановкой одного + поля», а склейка переменного числа частей, и собирать её шаблоном неудобно. + """ + parts = SUMMARY_PARTS.get(language, SUMMARY_PARTS[DEFAULT_LANGUAGE]) + keys = ("overdue", "today", "review") + text = " · ".join(parts[key].format(**counts) for key in keys if counts.get(key)) + return _clip(text, MAX_BODY) + + def render(kind: str, language: str, **fields: str) -> tuple[str, str] | None: """Собрать (заголовок, тело) для события; неизвестный kind → None. diff --git a/backend/app/services/reminders.py b/backend/app/services/reminders.py new file mode 100644 index 0000000..b9b9f7c --- /dev/null +++ b/backend/app/services/reminders.py @@ -0,0 +1,180 @@ +"""Правила напоминаний (ТЗ 3.21, подраздел «Напоминания»). + +Здесь только правила — **чистые функции**: ни сети, ни часов, ни базы. «Сегодня» +приходит аргументом, поэтому правила проверяются тестами без ожидания и без +подмены системного времени, а планировщик (`services/scheduler.py`) остаётся +тонким: собрать кандидатов, отсеять отправленное, отдать в push. + +Что напоминаем: + +- **строгий дедлайн** — накануне, в день срока и один раз о просрочке. Нестрогий + период («в течение недели/месяца/года») не напоминает вовсе: в модели у него нет + дня отсчёта, и выдумывать якорь владелец не захотел; +- **ритм регулярной задачи** — «пора: зарядка». День берётся тем же правилом, что + рождает следующий экземпляр (`services/recurrence.py`), но напоминание не ждёт + закрытия предыдущего — иначе о задаче вспоминаешь только тогда, когда её уже + сделал; +- **сводка на сегодня** — отдельная функция-окно, содержимое считает планировщик. + +Молчим о `done` и `cancelled` (работа сделана или отменена) и о `deferred`: +«сейчас не в приоритете» — это осознанное решение владельца, и напоминание о сроке +противоречило бы ему. +""" + +from collections.abc import Iterable +from dataclasses import dataclass, field +from datetime import date, datetime, time, timedelta + +from app.models import Task +from app.services import recurrence + +# Статусы, о которых напоминаем. `done`/`cancelled`/`deferred` — молчание +ACTIVE_STATUSES = ("to_do", "in_progress") + +# Виды напоминаний: они же — ключи текстов в services/push_texts.py и первая +# половина ключа дедупа в push_deliveries +KIND_DUE_TOMORROW = "task.due_tomorrow" +KIND_DUE_TODAY = "task.due_today" +KIND_OVERDUE = "task.overdue" +KIND_RHYTHM = "task.rhythm" +KIND_SUMMARY = "summary" + +# Окно сводки по локальному времени: утром её ещё читают, к вечеру она про +# прошедший день и смысла не имеет +SUMMARY_START_HOUR = 9 +SUMMARY_END_HOUR = 14 + + +@dataclass(frozen=True) +class Reminder: + """Напоминание до отправки: вид, ключ дедупа и поля для подстановки в текст.""" + + kind: str + # Уникален в пределах (пользователь, вид): что уже отправлено, решает + # push_deliveries. Перенос срока или новый день ритма дают новый ключ + ref_key: str + task_id: int | None + fields: dict[str, str] = field(default_factory=dict) + + +def is_remindable(task: Task) -> bool: + """Задача ещё живая: не сделана, не отменена, не отложена «на потом».""" + return task.status in ACTIVE_STATUSES + + +def _days_left(task: Task, today: date) -> int | None: + if task.deadline_date is None: + return None + return (task.deadline_date - today).days + + +def deadline_reminders(task: Task, today: date) -> list[Reminder]: + """Напоминания о строгом дедлайне: за день, в день и один раз о просрочке.""" + if not is_remindable(task): + return [] + deadline = task.deadline_date + if deadline is None: + return [] + days = (deadline - today).days + if days == 1: + kind = KIND_DUE_TOMORROW + elif days == 0: + kind = KIND_DUE_TODAY + elif days < 0: + kind = KIND_OVERDUE + else: + return [] + # Дата в ключе: срок перенесли — напоминание о новом сроке законное, а о + # прежнем уже отправлено и повторяться не должно. Просрочка тоже привязана к + # дате срока, поэтому звучит один раз, а не каждый день + ref_key = f"{task.id}:{deadline.isoformat()}" + return [Reminder(kind, ref_key, task.id, {"title": task.title})] + + +def rhythm_reminders(task: Task, today: date) -> list[Reminder]: + """«Пора» для регулярной задачи: сегодня по её же правилу повторения. + + День сверяем тем же `recurrence.next_date`, что создаёт следующий экземпляр: + отдельного «календаря напоминаний» не заводим, иначе правила разъедутся. + """ + if not is_remindable(task) or task.task_type != "recurring" or task.recur_kind is None: + return [] + # next_date ищет строго после переданной даты, поэтому «сегодня по правилу» + # проверяется как «следующая дата после вчера равна сегодня» + if recurrence.next_date(task, today - timedelta(days=1)) != today: + return [] + return [Reminder(KIND_RHYTHM, today.isoformat(), task.id, {"title": task.title})] + + +def task_reminders(task: Task, today: date) -> list[Reminder]: + """Все напоминания по одной задаче (может не быть ни одного).""" + return deadline_reminders(task, today) + rhythm_reminders(task, today) + + +# --- Тихие часы --------------------------------------------------------------- + + +def parse_quiet_hours(value: str | None) -> tuple[time, time] | None: + """Разобрать «HH:MM-HH:MM»; пусто или битое значение → тихих часов нет. + + Битое значение молча выключает тихие часы намеренно: это пользовательская + настройка, а не инвариант, и падать из-за опечатки в ней планировщик не должен. + """ + if not value: + return None + parts = value.split("-") + if len(parts) != 2: + return None + parsed: list[time] = [] + for part in parts: + try: + hour, minute = part.strip().split(":") + parsed.append(time(int(hour), int(minute))) + except ValueError: + return None + return parsed[0], parsed[1] + + +def in_quiet_hours(value: str | None, now: datetime) -> bool: + """Попадает ли момент в тихое окно (оно может пересекать полночь).""" + window = parse_quiet_hours(value) + if window is None: + return False + start, end = window + if start == end: + # Окно нулевой длины — не «весь день», а «выключено» + return False + moment = now.time() + if start < end: + return start <= moment < end + return moment >= start or moment < end + + +# --- Сводка ------------------------------------------------------------------- + + +def summary_due(now: datetime) -> bool: + """Сводка уместна в утренне-дневном окне (по локальному времени).""" + return SUMMARY_START_HOUR <= now.hour < SUMMARY_END_HOUR + + +def summary_counts(tasks: Iterable[Task], today: date) -> dict[str, int]: + """Что сказать в сводке: просрочено, срок сегодня и ждёт приёмки. + + Ждущие приёмки — закрытые агентом задачи (3.20): их статус `done`, но работа + не принята, и это единственное, что о них напоминает. + """ + counts = {"overdue": 0, "today": 0, "review": 0} + for task in tasks: + if task.accept_state == "pending": + counts["review"] += 1 + if not is_remindable(task): + continue + days = _days_left(task, today) + if days is None: + continue + if days < 0: + counts["overdue"] += 1 + elif days == 0: + counts["today"] += 1 + return counts diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..51893ed --- /dev/null +++ b/backend/app/services/scheduler.py @@ -0,0 +1,163 @@ +"""Планировщик напоминаний (ТЗ 3.21, подраздел «Напоминания»). + +Тонкая обвязка вокруг правил из `services/reminders.py`: правила ничего не знают +ни о часах, ни о базе, а здесь они получают «сегодня» по локальному поясу, +отсеиваются по журналу отправленного (`push_deliveries`) и уходят в push. + +Задача живёт в lifespan приложения, а не в отдельном процессе: напоминания — +часть приложения, и его перезапуск не должен оставлять сироту (см. `app/main.py`). +Итерация идёт в `asyncio.to_thread`: внутри синхронные SQLAlchemy и `requests`, и +в event loop их пускать нельзя. + +Дедуп — вставкой в `push_deliveries` **до** отправки. Тик 15-минутный, а правила +дневные, поэтому без журнала одна и та же просрочка приходила бы сотню раз; +обратный размен (упасть между вставкой и отправкой) даёт пропуск, а не дубль — +для напоминания это правильная сторона ошибки. +""" + +import asyncio +import logging +from datetime import UTC, date, datetime +from zoneinfo import ZoneInfo + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.db import get_session_factory +from app.models import AppSetting, PushDelivery, PushSubscription, Task +from app.services import push, push_texts, reminders + +logger = logging.getLogger(__name__) + +# Тик планировщика. Правила дневные, но пятнадцать минут дают попадание в окно +# сводки и в тихие часы с запасом, а стоят почти ничего +TICK_SECONDS = 900 + +# Тот же ключ, что `QUIET_HOURS_KEY` в api/settings.py: импортировать оттуда значило +# бы развернуть зависимость «сервис → API» ради одного строкового литерала +QUIET_HOURS_KEY = "quiet_hours" + + +async def scheduler_loop() -> None: + """Крутится, пока живёт приложение: тик, поток на итерацию, пауза. + + Падение одной итерации логируем и продолжаем: сбой на конкретной задаче не + повод перестать напоминать обо всех остальных до перезапуска API. + """ + while True: + try: + await asyncio.to_thread(run_iteration) + except asyncio.CancelledError: + raise # остановка приложения — не сбой итерации + except Exception: # noqa: BLE001 — цикл обязан пережить любую итерацию + logger.exception("напоминания: итерация планировщика упала") + await asyncio.sleep(TICK_SECONDS) + + +def quiet_hours(db: Session, user_id: str) -> str | None: + """Тихое окно пользователя («HH:MM-HH:MM») или None, если не задано.""" + row = db.get(AppSetting, (user_id, QUIET_HOURS_KEY)) + return row.value if row is not None else None + + +def run_iteration(now: datetime | None = None) -> None: + """Один проход: собрать напоминания всем подписанным устройствам и отправить. + + `now` — только для тестов и ручных прогонов; в бою берётся текущий момент. + Пользователи без подписок пропускаются сразу: напоминание некуда доставить, + а его журнал всё равно не нужен — при подписке оно придёт в свой день. + """ + settings = get_settings() + if not settings.push_enabled: + return + moment = (now or datetime.now(UTC)).astimezone(ZoneInfo(settings.reminder_timezone)) + today = moment.date() + db = get_session_factory()() + try: + user_ids = db.scalars(select(PushSubscription.user_id).distinct()).all() + for user_id in user_ids: + try: + _run_for_user(db, user_id, moment, today) + except Exception: # noqa: BLE001 — один пользователь не роняет остальных + db.rollback() + logger.exception("напоминания: сбой у пользователя %s", user_id) + finally: + db.close() + + +def _run_for_user(db: Session, user_id: str, moment: datetime, today: date) -> None: + """Собрать и отправить напоминания одного пользователя.""" + # Тихие часы: пропускаем целиком и **не пишем в журнал** — условие напоминания + # дневное («сегодня день D»), поэтому следующий тик после окна отправит его сам + if reminders.in_quiet_hours(quiet_hours(db, user_id), moment): + return + + tasks = db.scalars(select(Task).where(Task.user_id == user_id)).all() + language = push_texts.user_language(db, user_id) + pending: list[tuple[reminders.Reminder, str]] = [] + + for task in tasks: + for item in reminders.task_reminders(task, today): + payload = _payload(item, language) + if payload is not None: + pending.append((item, payload)) + + if reminders.summary_due(moment): + counts = reminders.summary_counts(tasks, today) + # Сводка без новостей — молчание, и в журнал она тоже не пишется: иначе + # завтрашняя сводка считалась бы отправленной сегодняшней + if any(counts.values()): + item = reminders.Reminder( + reminders.KIND_SUMMARY, + today.isoformat(), + None, + {"parts": push_texts.summary_body(language, counts)}, + ) + payload = _payload(item, language, url="/") + if payload is not None: + pending.append((item, payload)) + + if not pending: + return + fresh = _claim(db, user_id, pending) + db.commit() + for item, payload in fresh: + push.send_to_user(user_id, payload) + logger.info("напоминания: %s → %s (%s)", item.kind, user_id, item.ref_key) + + +def _payload(item: reminders.Reminder, language: str, url: str | None = None) -> str | None: + """Собрать payload напоминания; неизвестный вид (нет текста) → None.""" + rendered = push_texts.render(item.kind, language, **item.fields) + if rendered is None: + return None + title, body = rendered + target = url if url is not None else f"/tasks/{item.task_id}" + # tag как у событий агента: напоминание об одной задаче заменяет предыдущее, + # а не копится в шторке. У сводки задачи нет — её тег и есть вид + tag = f"{item.kind}:{item.task_id}" if item.task_id is not None else item.kind + return push.build_payload(kind=item.kind, title=title, body=body, url=target, tag=tag) + + +def _claim( + db: Session, user_id: str, pending: list[tuple[reminders.Reminder, str]] +) -> list[tuple[reminders.Reminder, str]]: + """Отметить напоминания отправленными; вернуть те, что отправляются впервые. + + Отметка идёт до отправки, поэтому повторный проход (или перезапуск API) те же + напоминания уже не отправит. SAVEPOINT на каждую вставку — чтобы нарушение + уникальности не откатило соседние отметки той же транзакции. + """ + fresh: list[tuple[reminders.Reminder, str]] = [] + for item, payload in pending: + savepoint = db.begin_nested() + db.add(PushDelivery(user_id=user_id, kind=item.kind, ref_key=item.ref_key)) + try: + savepoint.commit() + except IntegrityError: + savepoint.rollback() + continue + fresh.append((item, payload)) + return fresh diff --git a/backend/tests/test_push_api.py b/backend/tests/test_push_api.py index ae44a29..ae01ae5 100644 --- a/backend/tests/test_push_api.py +++ b/backend/tests/test_push_api.py @@ -1,8 +1,10 @@ """Тесты API системных уведомлений (ТЗ 3.21): config, подписки, изоляция. -Ключей VAPID в тестовом окружении нет — push выключен, и это первое, что -проверяем: без ключей подписываться некуда, API обязан сказать об этом честно, -а не падать на отправке позже. +По умолчанию ключей VAPID нет — push выключен, и это первое, что проверяем: без +ключей подписываться некуда, API обязан сказать об этом честно, а не падать на +отправке позже. Ключи в тестах включаются точечно (`vapid_keys`), причём фикстура +ниже глушит и те, что лежат в `.env` разработчика: набор не должен зависеть от +того, завёл ли он себе push. """ from collections.abc import Iterator @@ -24,6 +26,15 @@ PAYLOAD = {"endpoint": ENDPOINT, "keys": {"p256dh": "p256dh-key", "auth": "auth-key"}} +@pytest.fixture(autouse=True) +def _no_vapid_keys() -> Iterator[None]: + settings = get_settings() + old = (settings.vapid_public_key, settings.vapid_private_key, settings.vapid_subject) + settings.vapid_public_key = settings.vapid_private_key = settings.vapid_subject = "" + yield + settings.vapid_public_key, settings.vapid_private_key, settings.vapid_subject = old + + @contextmanager def vapid_keys(public: str = "pub-key", private: str = "priv-key") -> Iterator[None]: """Включить push на время теста: ключи живут в настройках, а не в БД.""" diff --git a/backend/tests/test_push_scheduler.py b/backend/tests/test_push_scheduler.py new file mode 100644 index 0000000..3ffe48e --- /dev/null +++ b/backend/tests/test_push_scheduler.py @@ -0,0 +1,217 @@ +"""Планировщик напоминаний: дедуп через журнал (ТЗ 3.21, 0.89). + +Главное свойство, которое здесь проверяется, — «одно напоминание на один повод» +переживает и повторный тик, и перезапуск API. Тик 15-минутный, а правила дневные, +поэтому без журнала одна и та же просрочка приходила бы сотню раз. + +Отправка наружу подменена: проверяем решение планировщика, а не сеть. +""" + +from datetime import date, datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +import pytest +from sqlalchemy import select + +from app.config import get_settings +from app.models import AppSetting, PushDelivery, PushSubscription, Task, User +from app.services import scheduler +from tests.conftest import _test_session_factory # type: ignore[attr-defined] + +# Моменты задаём прямо в поясе напоминаний: планировщик переводит «сейчас» в него +# сам, и тест, написанный в UTC, проверял бы не то время суток +TZ = ZoneInfo(get_settings().reminder_timezone) +# 11:00 — внутри окна сводки (09:00–14:00) и вне тихих часов по умолчанию +MORNING = datetime(2026, 10, 10, 11, 0, tzinfo=TZ) +TODAY = date(2026, 10, 10) + + +@pytest.fixture(autouse=True) +def test_db(monkeypatch: Any) -> None: + """Планировщик открывает свою сессию — направляем её в тестовую БД.""" + monkeypatch.setattr(scheduler, "get_session_factory", lambda: _test_session_factory) + + +@pytest.fixture(autouse=True) +def vapid() -> Any: + """Без ключей VAPID итерация выходит сразу — тестам нужен включённый push.""" + settings = get_settings() + old = (settings.vapid_public_key, settings.vapid_private_key) + settings.vapid_public_key = "pub-key" + settings.vapid_private_key = "priv-key" + yield settings + settings.vapid_public_key, settings.vapid_private_key = old + + +@pytest.fixture +def sent(monkeypatch: Any) -> list[tuple[str, str]]: + """Отправленное: (user_id, payload). Настоящий webpush в тестах не зовётся.""" + calls: list[tuple[str, str]] = [] + + def record(user_id: str, payload: str, **kwargs: Any) -> None: + calls.append((user_id, payload)) + + monkeypatch.setattr(scheduler.push, "send_to_user", record) + return calls + + +def _seed(db: Any, **task_fields: Any) -> Task: + db.add(User(id="1", email="a@b.c", locale="ru")) + task = Task(user_id="1", title="Проверить отчёт", **task_fields) + db.add(task) + db.add(PushSubscription(user_id="1", endpoint="https://push/1", p256dh="p", auth="a")) + db.commit() + return task + + +def _db() -> Any: + return _test_session_factory() + + +def _kinds(sent: list[tuple[str, str]]) -> list[str]: + import json + + return [json.loads(payload)["kind"] for _, payload in sent] + + +def test_iteration_sends_overdue_and_writes_journal(sent: list[tuple[str, str]]) -> None: + db = _db() + try: + _seed(db, deadline_date=TODAY - timedelta(days=1)) + finally: + db.close() + + scheduler.run_iteration(MORNING) + assert _kinds(sent) == ["task.overdue", "summary"] + + db = _db() + try: + assert [row.kind for row in db.scalars(select(PushDelivery))] == [ + "task.overdue", + "summary", + ] + finally: + db.close() + + +def test_second_pass_sends_nothing(sent: list[tuple[str, str]]) -> None: + """Тот же тик ещё раз: повод тот же — молчим.""" + db = _db() + try: + _seed(db, deadline_date=TODAY) + finally: + db.close() + + scheduler.run_iteration(MORNING) + assert _kinds(sent) == ["task.due_today", "summary"] + sent.clear() + + scheduler.run_iteration(MORNING + timedelta(minutes=15)) + assert sent == [] + + +def test_restart_of_api_does_not_duplicate(sent: list[tuple[str, str]]) -> None: + """Перезапуск контейнера — новая сессия, а журнал тот же: дублей быть не должно.""" + db = _db() + try: + _seed(db, deadline_date=TODAY - timedelta(days=3)) + finally: + db.close() + + scheduler.run_iteration(MORNING) + sent.clear() + + db = _db() # «новый процесс»: свежая сессия, тот же журнал в БД + try: + assert db.scalars(select(PushDelivery)).all() != [] + finally: + db.close() + scheduler.run_iteration(MORNING + timedelta(hours=1)) + assert sent == [] + + +def test_quiet_hours_skip_and_leave_no_trace(sent: list[tuple[str, str]]) -> None: + """В тихом окне не отправляем и **не пишем в журнал**: условие дневное, и + следующий тик после окна обязан напомнить сам.""" + db = _db() + try: + _seed(db, deadline_date=TODAY) + db.add(AppSetting(user_id="1", key="quiet_hours", value="22:00-09:00")) + db.commit() + finally: + db.close() + + scheduler.run_iteration(datetime(2026, 10, 10, 7, 30, tzinfo=TZ)) # внутри окна + assert sent == [] + + db = _db() + try: + assert db.scalars(select(PushDelivery)).all() == [] + finally: + db.close() + + # после окна напоминание приходит само — молчание не съело повод + scheduler.run_iteration(MORNING) + assert "task.due_today" in _kinds(sent) + + +def test_summary_is_silent_when_there_is_nothing_to_say(sent: list[tuple[str, str]]) -> None: + """Сводка без новостей — молчание, и в журнал она тоже не идёт.""" + db = _db() + try: + _seed(db, deadline_date=TODAY + timedelta(days=10)) + finally: + db.close() + + scheduler.run_iteration(MORNING) + assert sent == [] + + db = _db() + try: + assert db.scalars(select(PushDelivery)).all() == [] + finally: + db.close() + + +def test_summary_payload_has_no_task_in_tag(sent: list[tuple[str, str]]) -> None: + """У сводки задачи нет: тег «summary:None» в шторке выглядел бы поломкой.""" + import json + + db = _db() + try: + _seed(db, deadline_date=TODAY) + finally: + db.close() + + scheduler.run_iteration(MORNING) + summary = [json.loads(p) for _, p in sent if json.loads(p)["kind"] == "summary"][0] + assert summary["tag"] == "summary" + assert summary["url"] == "/" + + +def test_user_without_devices_is_skipped(sent: list[tuple[str, str]]) -> None: + db = _db() + try: + db.add(User(id="1", email="a@b.c", locale="ru")) + db.add(Task(user_id="1", title="Просрочено", deadline_date=TODAY - timedelta(days=1))) + db.commit() + finally: + db.close() + + scheduler.run_iteration(MORNING) + assert sent == [] + + +def test_push_off_stops_the_scheduler(sent: list[tuple[str, str]], vapid: Any) -> None: + """Нет ключей VAPID — push выключен целиком, итерация не делает ничего.""" + db = _db() + try: + _seed(db, deadline_date=TODAY) + finally: + db.close() + + vapid.vapid_public_key = "" + vapid.vapid_private_key = "" + scheduler.run_iteration(MORNING) + assert sent == [] diff --git a/backend/tests/test_reminders.py b/backend/tests/test_reminders.py new file mode 100644 index 0000000..6d40c05 --- /dev/null +++ b/backend/tests/test_reminders.py @@ -0,0 +1,175 @@ +"""Правила напоминаний (ТЗ 3.21, 0.89). + +Здесь только чистые функции: «сегодня» — аргумент, часов и базы нет. Поэтому +проверяем календарные границы всеми способами, которые ломаются на живых данных: +срок перенесли, задача просрочена давно, регулярная выпала на сегодня, а тихое +окно пересекает полночь. +""" + +from datetime import UTC, date, datetime, timedelta +from typing import Any + +from app.models import Task +from app.services import reminders + +TODAY = date(2026, 10, 10) + + +def _task(task_id: int = 1, **fields: Any) -> Task: + """Задача в памяти: правила не ходят в базу, ей хватает атрибутов. + + Статус и `created_at` задаём руками: у модели это `default`, который + срабатывает на вставке, а до неё объект ещё «пустой». + """ + fields.setdefault("status", "to_do") + task = Task(id=task_id, user_id="1", title="Задача", **fields) + task.created_at = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + return task + + +def _kinds(items: list[reminders.Reminder]) -> list[str]: + return [item.kind for item in items] + + +# --- Строгий срок ------------------------------------------------------------- + + +def test_deadline_reminds_day_before_today_and_overdue() -> None: + tomorrow = reminders.deadline_reminders( + _task(deadline_date=TODAY + timedelta(days=1)), TODAY + ) + assert _kinds(tomorrow) == [reminders.KIND_DUE_TOMORROW] + + due = reminders.deadline_reminders(_task(deadline_date=TODAY), TODAY) + assert _kinds(due) == [reminders.KIND_DUE_TODAY] + + late = reminders.deadline_reminders(_task(deadline_date=TODAY - timedelta(days=40)), TODAY) + assert _kinds(late) == [reminders.KIND_OVERDUE] + + +def test_deadline_keeps_silence_far_ahead() -> None: + """За неделю до срока напоминать нечего — иначе напоминание превращается в шум.""" + assert reminders.deadline_reminders(_task(deadline_date=TODAY + timedelta(days=7)), TODAY) == [] + + +def test_soft_deadline_never_reminds() -> None: + """«В течение недели/месяца/года» — периода без дня отсчёта достаточно, чтобы молчать.""" + assert reminders.deadline_reminders(_task(deadline_period="week"), TODAY) == [] + assert reminders.deadline_reminders(_task(deadline_period="year"), TODAY) == [] + + +def test_overdue_fires_once_per_deadline_date() -> None: + """Просрочка звучит один раз: ключ привязан к дате срока, а не к «сегодня».""" + task = _task(deadline_date=TODAY - timedelta(days=3)) + first = reminders.deadline_reminders(task, TODAY)[0] + later = reminders.deadline_reminders(task, TODAY + timedelta(days=5))[0] + assert first.ref_key == later.ref_key + + # перенесли срок — напоминание о новом сроке законное, ключ другой + task.deadline_date = TODAY + timedelta(days=1) + moved = reminders.deadline_reminders(task, TODAY)[0] + assert moved.ref_key != first.ref_key + + +def test_closed_and_deferred_tasks_are_silent() -> None: + """`done`/`cancelled` — работа кончилась, `deferred` — владелец сам отложил.""" + for status in ("done", "cancelled", "deferred"): + task = _task(status=status, deadline_date=TODAY) + assert reminders.task_reminders(task, TODAY) == [], status + + +# --- Ритм регулярной задачи --------------------------------------------------- + + +def test_rhythm_reminds_on_its_own_day() -> None: + """«Пора: зарядка» — в день, который правило повторения и так назначило.""" + daily = _task(task_type="recurring", recur_kind="interval", recur_interval_days=1) + assert _kinds(reminders.rhythm_reminders(daily, TODAY)) == [reminders.KIND_RHYTHM] + # ключ — дата: завтра это уже другое напоминание, а сегодняшнее не повторится + assert reminders.rhythm_reminders(daily, TODAY)[0].ref_key == TODAY.isoformat() + assert reminders.rhythm_reminders(daily, TODAY + timedelta(days=1))[0].ref_key != ( + TODAY.isoformat() + ) + + +def test_rhythm_skips_other_days() -> None: + """Каждые три дня от 1 сентября: 10 октября — да, 9-е — нет.""" + every_three = _task(task_type="recurring", recur_kind="interval", recur_interval_days=3) + assert reminders.rhythm_reminders(every_three, TODAY) != [] + assert reminders.rhythm_reminders(every_three, TODAY - timedelta(days=1)) == [] + + +def test_rhythm_ignores_plain_and_ruleless_tasks() -> None: + assert reminders.rhythm_reminders(_task(), TODAY) == [] + assert reminders.rhythm_reminders(_task(task_type="recurring", recur_kind=None), TODAY) == [] + + +def test_one_task_can_give_both_reminders() -> None: + """Регулярная задача со строгим сроком говорит дважды — это разные поводы.""" + task = _task( + task_type="recurring", + recur_kind="interval", + recur_interval_days=1, + deadline_date=TODAY, + ) + assert _kinds(reminders.task_reminders(task, TODAY)) == [ + reminders.KIND_DUE_TODAY, + reminders.KIND_RHYTHM, + ] + + +# --- Тихие часы --------------------------------------------------------------- + + +def test_quiet_hours_within_same_day() -> None: + window = "13:00-15:00" + assert reminders.in_quiet_hours(window, datetime(2026, 10, 10, 14, 0)) + assert not reminders.in_quiet_hours(window, datetime(2026, 10, 10, 15, 0)) + assert not reminders.in_quiet_hours(window, datetime(2026, 10, 10, 12, 59)) + + +def test_quiet_hours_across_midnight() -> None: + """Окно 22:00–09:00 — то, ради чего тихие часы и заводят.""" + window = "22:00-09:00" + assert reminders.in_quiet_hours(window, datetime(2026, 10, 10, 23, 30)) + assert reminders.in_quiet_hours(window, datetime(2026, 10, 10, 8, 59)) + assert not reminders.in_quiet_hours(window, datetime(2026, 10, 10, 9, 0)) + assert not reminders.in_quiet_hours(window, datetime(2026, 10, 10, 21, 59)) + + +def test_broken_or_empty_quiet_hours_disable_the_window() -> None: + """Битая настройка — не инвариант: опечатка выключает окно, а не ломает планировщик.""" + for value in (None, "", "22:00", "22:00-", "25:00-09:00", "вечер-утро", "22:00-22:00"): + assert reminders.parse_quiet_hours(value) is None or not reminders.in_quiet_hours( + value, datetime(2026, 10, 10, 23, 0) + ), value + + +# --- Сводка ------------------------------------------------------------------- + + +def test_summary_window_is_morning_to_early_afternoon() -> None: + assert not reminders.summary_due(datetime(2026, 10, 10, 8, 59)) + assert reminders.summary_due(datetime(2026, 10, 10, 9, 0)) + assert reminders.summary_due(datetime(2026, 10, 10, 13, 59)) + assert not reminders.summary_due(datetime(2026, 10, 10, 14, 0)) + + +def test_summary_counts_what_needs_attention() -> None: + tasks = [ + _task(1, deadline_date=TODAY - timedelta(days=2)), + _task(2, deadline_date=TODAY), + _task(3, status="done", accept_state="pending"), # работа агента ждёт приёмки + _task(4, deadline_date=TODAY, status="done"), # сделана — в счёт не идёт + _task(5, deadline_period="week"), + ] + assert reminders.summary_counts(tasks, TODAY) == {"overdue": 1, "today": 1, "review": 1} + + +def test_summary_counts_zero_when_nothing_to_say() -> None: + """Ноль по всем счётчикам — планировщик молчит (и в журнал не пишет).""" + assert reminders.summary_counts([_task(deadline_date=TODAY + timedelta(days=5))], TODAY) == { + "overdue": 0, + "today": 0, + "review": 0, + } diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 003e5d7..252b91b 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -1,27 +1,65 @@ -"""Тесты глобальных настроек: валюта и язык UI.""" +"""Тесты глобальных настроек: валюта, язык UI и тихие часы напоминаний.""" from fastapi.testclient import TestClient def test_default_settings(client: TestClient) -> None: - # по умолчанию UAH, язык не переопределён (следует SSO locale) - assert client.get("/api/settings").json() == {"currency": "UAH", "language": ""} + # по умолчанию UAH, язык не переопределён (следует SSO locale), + # тихие часы не заданы (0.89) + assert client.get("/api/settings").json() == { + "currency": "UAH", + "language": "", + "quiet_hours": "", + } def test_update_currency_and_language(client: TestClient) -> None: body = client.put("/api/settings", json={"currency": "EUR", "language": "en"}).json() - assert body == {"currency": "EUR", "language": "en"} - assert client.get("/api/settings").json() == {"currency": "EUR", "language": "en"} + assert body == {"currency": "EUR", "language": "en", "quiet_hours": ""} + assert client.get("/api/settings").json() == body def test_partial_put_keeps_other_fields(client: TestClient) -> None: client.put("/api/settings", json={"currency": "USD", "language": "uk"}) # PUT только языка не затирает валюту body = client.put("/api/settings", json={"language": "ru"}).json() - assert body == {"currency": "USD", "language": "ru"} + assert body == {"currency": "USD", "language": "ru", "quiet_hours": ""} # PUT только валюты не затирает язык body = client.put("/api/settings", json={"currency": "GBP"}).json() - assert body == {"currency": "GBP", "language": "ru"} + assert body == {"currency": "GBP", "language": "ru", "quiet_hours": ""} + + +def test_quiet_hours_round_trip(client: TestClient) -> None: + """Окно 22:00–09:00 переживает запись и чтение: по нему молчит планировщик.""" + assert client.put("/api/settings", json={"quiet_hours": "22:00-09:00"}).json() == { + "currency": "UAH", + "language": "", + "quiet_hours": "22:00-09:00", + } + assert client.get("/api/settings").json()["quiet_hours"] == "22:00-09:00" + # пустая строка — «тихих часов нет» + assert client.put("/api/settings", json={"quiet_hours": ""}).json()["quiet_hours"] == "" + + +def test_broken_quiet_hours_read_as_not_set(client: TestClient) -> None: + """Битое значение в базе наружу не отдаём: планировщик его всё равно не разберёт, + а интерфейс не должен показывать окно, по которому ничего не молчит.""" + from app.models import AppSetting + from tests.conftest import _test_session_factory # type: ignore[attr-defined] + + session = _test_session_factory() + try: + session.add(AppSetting(user_id="1", key="quiet_hours", value="вечер-утро")) + session.commit() + finally: + session.close() + assert client.get("/api/settings").json()["quiet_hours"] == "" + + +def test_invalid_quiet_hours_422(client: TestClient) -> None: + assert client.put("/api/settings", json={"quiet_hours": "вечер-утро"}).status_code == 422 + assert client.put("/api/settings", json={"quiet_hours": "25:00-09:00"}).status_code == 422 + assert client.put("/api/settings", json={"quiet_hours": "22:00"}).status_code == 422 def test_clear_language_override(client: TestClient) -> None: diff --git a/docs/TZ.md b/docs/TZ.md index 6765c91..e218094 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.88 | +| Версия ТЗ | 0.89 | | Дата | 2026-10-10 | | Статус | На обсуждении | @@ -1313,7 +1313,7 @@ случайности: агент не тронет задачу, которую владелец ему не поручал, а всё, что он сделал, видно в журнале. Строить на `ai_eligible` разграничение прав нельзя. -### 3.21. Системные уведомления (Web Push) (0.88) +### 3.21. Системные уведомления (Web Push) (0.88–0.89) Работа ИИ-агента идёт, пока приложение закрыто, и до 0.88 владелец узнавал о ней только из открытой вкладки (3.14, 3.20). Системные уведомления закрывают этот пробел: те же @@ -1328,7 +1328,8 @@ **2. Что приходит уведомлением.** Работа агента: «взял задачу» (`task.claimed`) и «закрыл — ждёт приёмки» (`task.review`). Это ровно те события, о которых владелец иначе -не узнает, пока не откроет приложение. +не узнает, пока не откроет приложение. Напоминания (0.89) — второй источник: строгий +дедлайн, ритм регулярной задачи и утренняя сводка; их правила — в пункте 9. **3. Подавление при видимой вкладке.** Перед показом воркер смотрит на окна приложения того же источника и, если есть видимое, уведомление не показывает: там работает @@ -1367,6 +1368,47 @@ Уведомления в журнал `task_events` не пишутся: он про действия с задачами, а не про доставку. +**9. Напоминания (0.89).** Приложение молчит, пока его не откроют, поэтому о сроке и о +регулярном деле напоминает планировщик — не только открытая вкладка. + +*Что напоминаем.* **Строгий дедлайн** — накануне срока, в день срока и один раз о +просрочке. **Ритм регулярной задачи** — «пора: зарядка» в день, который правило +повторения (3.5) и так назначило; день считается тем же `recurrence.next_date`, что +создаёт следующий экземпляр, отдельного «календаря напоминаний» нет, иначе правила +разъехались бы. Следствием этого задача, созданная сегодня, впервые напомнит о себе в +свой следующий день: сегодняшний экземпляр у владельца и так на руках. **Утренняя +сводка** — «просрочено / срок сегодня / ждут приёмки», отправляется в окне 09:00–14:00 +по локальному времени и молчит (не отправляется и не пишется в журнал), если сказать +нечего. + +*Что не напоминаем.* **Нестрогий срок** («в течение недели/месяца/года») не напоминает +вовсе: в модели это период без дня отсчёта, разворачивать его в дату мы не стали. +Задачи в статусах `done`, `cancelled` и **`deferred`** — тоже молчание: «сейчас не в +приоритете» это осознанное решение владельца, и напоминание о сроке противоречило бы +ему. + +*Дневная гранулярность.* Итерация планировщика идёт каждые 15 минут, но поводы +напоминаний дневные: напоминание не привязано к часу, и «просрочено» звучит один раз, а +не каждые сутки. + +*Дедуп.* Отправленное записывается в `push_deliveries` (`unique(user_id, kind, +ref_key)`) — **до** отправки. Поэтому повторный тик и перезапуск контейнера дублей не +дают, а перенос срока рождает новый повод: в `ref_key` входит дата срока, у ритма — +день, о котором напоминаем. Обратный размен (падение между записью и отправкой) даёт +пропуск, а не дубль: для напоминания это правильная сторона ошибки. + +*Тихие часы.* Настройка `quiet_hours` («HH:MM-HH:MM», `app_settings`) — окно, в которое +напоминания не приходят; в интерфейсе это переключатель с окном 22:00–09:00. Окно может +пересекать полночь. Попавшее в окно напоминание **не отправляется и не записывается**: +условие дневное, поэтому следующий тик после окна отправит его сам. Работа агента +тихими часами не глушится — она ждёт решения владельца. + +*Где живёт планировщик.* Задача в lifespan приложения: итерация уходит в отдельный +поток (внутри синхронные SQLAlchemy и HTTP-запросы к push-сервису), а цикл отменяется +вместе с приложением. Сбой одной итерации логируется, но не прекращает напоминания до +перезапуска. Пояс напоминаний — из окружения (`REMINDER_TIMEZONE`), один на инстанс +(открытый вопрос 8.13). + ## 4. Нефункциональные требования | Требование | Значение | @@ -1522,6 +1564,14 @@ (ключи шифрования устройства), user_agent, created_at, last_success_at, failure_count — мёртвые подписки (404/410 и постоянные ошибки) удаляются лениво, при отправке + +PushDelivery — журнал отправленных напоминаний (3.21, 0.89): user_id (NOT NULL, + ON DELETE CASCADE), kind (вид напоминания), ref_key (повод: + задача и дата срока, либо день ритма, либо дата сводки), + created_at, unique(user_id, kind, ref_key). Защита от дублей: + запись идёт до отправки, поэтому повторный тик и перезапуск + контейнера не рассылают одно и то же. Не чистится — ретенция + в открытом вопросе 8.14 ``` История завершённых задач (фактическое время) — источник для прогнозирования длительности. @@ -1557,7 +1607,7 @@ | M5 — MCP | MCP-сервер, инструменты для агентов | | M5+ — мандат агента | Доступность задачи для ИИ-агента, взятие в работу (аренда), приёмка работы, журнал `task_events` (0.85, 3.20) | | M6 — мобильность | PWA (Android-приложение — позже, вне стартового скоупа) | -| M6+ — уведомления | Системные уведомления (Web Push): подписки устройств, ключи VAPID, блок настроек, работа агента и напоминания в закрытом приложении (0.88, 3.21) | +| M6+ — уведомления | Системные уведомления (Web Push): подписки устройств, ключи VAPID, блок настроек, работа агента в закрытом приложении (0.88, 3.21). Напоминания: дедлайны, ритм регулярных задач, утренняя сводка, тихие часы (0.89, 3.21) | | M7 — упаковка | Docker (docker-compose: API, PostgreSQL, фронт, Ollama + модель) | Порядок M4/M5 может меняться — MCP можно поднять раньше ради ИИ-сценария. @@ -1575,4 +1625,6 @@ 9. **Premium-виды в семенах** — **отложено вместе с садом (0.58)**: premium-виды (сейчас кактус) рождаются только из rare/epic закрытий — «отпечаток крупной работы». Семена же продаются за монеты и дают common-растение; сейчас семя premium-вида купить можно. Оставить ли покупку семян premium-видов (простой доступ к виду) или запретить (эксклюзив rare/epic сохраняется) — решить при возврате сада. 10. **Самосоединяющийся забор** — **отложено вместе с садом (0.58)**: секции изгороди автоматически соединяются с соседними секциями (маска соседей N/E/S/W → текстура); ручного поворота нет. Соединяются только соседние секции забора — диагонали не считаются. Подтвердить при возврате сада. 11. **Возврат сада** (0.58): когда основной функционал будет в порядке, вернуть сад к доработке — выдача растений включится сама, если UI снова начнёт звать `GET /api/garden`; вопросы — что переделать в саду (сцена, маркет, прогрессия) — решать тогда, на накопленных данных. -12. **Ретенция журнала** (0.85): `task_events` пишет всё жизненное, включая смену статуса, и растёт вместе с работой. Пока пагинации и потолка `per_page` хватает; решить позже — нужен ли срок хранения, архивация старых записей или их сжатие по задаче. \ No newline at end of file +12. **Ретенция журнала** (0.85): `task_events` пишет всё жизненное, включая смену статуса, и растёт вместе с работой. Пока пагинации и потолка `per_page` хватает; решить позже — нужен ли срок хранения, архивация старых записей или их сжатие по задаче. +13. **Часовой пояс напоминаний** (0.89): `REMINDER_TIMEZONE` — один на инстанс, поэтому «сегодня» и окно сводки считаются по поясу сервера, а не по поясу владельца. Для личного развёртывания этого достаточно (сервер и владелец в одном поясе), но при переезде или втором пользователе в другом поясе напоминания придут не в его утро. Решать вместе с вопросом мультипользовательности: пояс в профиле или вычислять по последней активности устройства. +14. **Ретенция `push_deliveries`** (0.89): журнал отправленного растёт по одной строке на повод (задача + срок, день ритма, день сводки) и не чистится — старые записи нужны только до конца своего дня. Строки дешёвые, но вечные: решить, удалять ли их по возрасту (например, старше месяца) регулярной уборкой или оставить как историю отправленного. \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8788936..3ddbf6f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -168,6 +168,8 @@ currency: string /** '' = нет переопределения, язык берётся из SSO locale */ language: string + /** Тихие часы напоминаний «HH:MM-HH:MM»; '' = не заданы (ТЗ 3.21, 0.89) */ + quiet_hours: string } export type AppSettingsUpdate = Partial diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index a6c4ac1..3500523 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -646,6 +646,9 @@ pushRequestDenied: 'The browser did not show the prompt. Allow notifications for this site in the browser settings and try again.', pushFailed: 'Could not enable notifications', + quietSwitch: 'Do not disturb from 22:00 to 09:00', + quietHint: + 'Reminders stay silent during this window and are not queued: once it ends you get a reminder for the state of things that day. Agent work is reported even during quiet hours — it awaits your decision.', }, ext: { title: 'Quick-capture extension', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index 8954c00..4d955b4 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -652,6 +652,9 @@ pushRequestDenied: 'Браузер не показал запрос. Включите уведомления для сайта в настройках браузера и попробуйте снова.', pushFailed: 'Не удалось включить уведомления', + quietSwitch: 'Не беспокоить с 22:00 до 09:00', + quietHint: + 'В это окно напоминания молчат и в очередь не встают: как только окно кончится, придёт напоминание по сегодняшнему состоянию дел. О работе агента сообщаем и в тихие часы — она ждёт вашего решения.', }, ext: { title: 'Расширение быстрого захвата', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index feb91ca..d0d25e1 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -646,6 +646,9 @@ pushRequestDenied: 'Браузер не показав запит. Увімкніть сповіщення для сайту в налаштуваннях браузера і спробуйте ще раз.', pushFailed: 'Не вдалося увімкнути сповіщення', + quietSwitch: 'Не турбувати з 22:00 до 09:00', + quietHint: + 'У цьому вікні нагадування мовчать і в чергу не стають: щойно вікно скінчиться, прийде нагадування за станом справ на той день. Про роботу агента повідомляємо і в тихі години — вона чекає на ваше рішення.', }, ext: { title: 'Розширення швидкого захоплення', diff --git a/frontend/src/sw.ts b/frontend/src/sw.ts index 85d785f..577259d 100644 --- a/frontend/src/sw.ts +++ b/frontend/src/sw.ts @@ -24,16 +24,27 @@ // Манифест precache вписывает плагин на этапе сборки; строка должна остаться // буквальной — по ней он и ищет точку вставки -precacheAndRoute(self.__WB_MANIFEST) +const precache = self.__WB_MANIFEST +precacheAndRoute(precache) // SPA-fallback: любой переход внутри приложения отдаём из кэша. // /api, /auth и /mcp из него исключены — на них отвечает сервер, и ответ -// index.html'ом был бы ложью (401 вместо разлогина, JSON вместо страницы) -registerRoute( - new NavigationRoute(createHandlerBoundToURL('index.html'), { - denylist: [/^\/api\//, /^\/auth\//, /^\/mcp\//], - }), +// index.html'ом был бы ложью (401 вместо разлогина, JSON вместо страницы). +// +// Ставим маршрут только если index.html и правда в precache: в dev манифест +// пуст (VITE_PWA_DEV=1), а createHandlerBoundToURL на незнакомом URL бросает +// исключение прямо при вычислении воркера — регистрация падала бы целиком, +// и отлаживать push было бы негде +const hasAppShell = precache.some( + (entry) => (typeof entry === 'string' ? entry : entry.url) === 'index.html', ) +if (hasAppShell) { + registerRoute( + new NavigationRoute(createHandlerBoundToURL('index.html'), { + denylist: [/^\/api\//, /^\/auth\//, /^\/mcp\//], + }), + ) +} // Вложения и ассеты кита: сначала кэш, сеть — только если файла там нет. // Оба неизменяемы по адресу, поэтому CacheFirst безопасен diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index b7132d2..4ce225c 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -38,6 +38,14 @@ const pushBusy = ref(false) const pushDevices = ref(0) +// Тихие часы (0.89). Окно в интерфейсе одно: свободный ввод «HH:MM-HH:MM» API +// принимает, но полем его пришлось бы объяснять, а заводят эту настройку ради +// одного — «ночью не будить». Значение живёт на сервере: по нему молчит +// планировщик, и часы браузера тут ни при чём +const QUIET_HOURS = '22:00-09:00' +const quietOn = ref(false) +const quietBusy = ref(false) + const pushNote = computed(() => { switch (pushKind.value) { case 'on': @@ -92,6 +100,19 @@ { value: 'uk', label: t('settings.langUk') }, ]) +async function toggleQuiet(enabled: boolean) { + if (quietBusy.value) return + quietBusy.value = true + try { + const s = await api.updateSettings({ quiet_hours: enabled ? QUIET_HOURS : '' }) + quietOn.value = s.quiet_hours !== '' + } catch (e) { + toast.error({ title: t('common.error'), text: String(e) }) + } finally { + quietBusy.value = false + } +} + async function loadTokens() { try { tokens.value = await api.listMcpTokens() @@ -105,6 +126,7 @@ const s = await api.getSettings() currency.value = s.currency language.value = s.language + quietOn.value = s.quiet_hours !== '' } catch (e) { error.value = String(e) } @@ -282,6 +304,13 @@

{{ t('settings.pushDevices', { n: pushDevices }) }}

+ +

{{ t('settings.quietHint') }}