fix(hands,memory): the daily routine folds into one line and stops crowding out single events
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
FREQ=DAILY/WEEKLY/MONTHLY/YEARLY с INTERVAL, BYDAY, BYMONTH, COUNT и UNTIL;
|
||||
EXDATE и перенесённые экземпляры (RECURRENCE-ID) вычитаются. Шаг считается по
|
||||
настенным часам события, а не в абсолютном времени: еженедельная встреча
|
||||
переживает перевод часов.
|
||||
переживает перевод часов. Событие, порождённое ежедневным правилом, помечается
|
||||
`routine`: в справке такие едут одной строкой, чтобы не вытеснять разовые.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -32,7 +33,16 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from datetime import tzinfo
|
||||
|
||||
__all__ = ["Calendars", "Event", "Feed", "Upcoming", "calendars", "feeds", "short"]
|
||||
__all__ = [
|
||||
"Calendars",
|
||||
"Event",
|
||||
"Feed",
|
||||
"Upcoming",
|
||||
"calendars",
|
||||
"feeds",
|
||||
"is_routine",
|
||||
"short",
|
||||
]
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -108,6 +118,8 @@ class Event:
|
||||
title: str
|
||||
place: str
|
||||
all_day: bool
|
||||
routine: bool = False
|
||||
"""Порождено правилом, повторяющимся раз в день или чаще: см. `is_routine`."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -425,6 +437,25 @@ def _until(value: str | None, zone: tzinfo) -> datetime | None:
|
||||
return stop + timedelta(days=1) if all_day else stop
|
||||
|
||||
|
||||
ROUTINE_DAYS = 5
|
||||
"""Со скольких дней недели в BYDAY повтор перестаёт быть событием и делается бытом."""
|
||||
|
||||
|
||||
def is_routine(rule: dict[str, str]) -> bool:
|
||||
"""Правило, срабатывающее раз в день или чаще: завтрак, зарядка, wind down.
|
||||
|
||||
Такие повторы за неделю дают десятки строк и топят в себе то единственное,
|
||||
ради чего справку и читают, - экзамен, встречу, приём. В конверт они едут
|
||||
одной свёрнутой строкой, а не списком.
|
||||
"""
|
||||
freq = rule.get("FREQ", "").upper()
|
||||
if max(_int(rule.get("INTERVAL"), 1), 1) != 1:
|
||||
return False
|
||||
if freq == "DAILY":
|
||||
return True
|
||||
return freq == "WEEKLY" and len(_byday(rule.get("BYDAY", ""))) >= ROUTINE_DAYS
|
||||
|
||||
|
||||
def place_of(event: Vevent) -> str:
|
||||
"""Зал из описания и адрес из LOCATION - то, что помогает дойти."""
|
||||
parts: list[str] = []
|
||||
@@ -471,8 +502,11 @@ def events_in(text: str, *, zone: tzinfo, since: datetime, days: int) -> list[Ev
|
||||
if rrule is None or "RECURRENCE-ID" in event:
|
||||
starts = [start]
|
||||
skip: set[datetime] = set()
|
||||
routine = False
|
||||
else:
|
||||
starts = occurrences(start, _rule(rrule.value), zone, end=end)
|
||||
rule = _rule(rrule.value)
|
||||
starts = occurrences(start, rule, zone, end=end)
|
||||
routine = is_routine(rule)
|
||||
uid = _first(event, "UID")
|
||||
skip = _instants(event, "EXDATE", zone) | moved.get(
|
||||
uid.value if uid else "", set()
|
||||
@@ -483,7 +517,15 @@ def events_in(text: str, *, zone: tzinfo, since: datetime, days: int) -> list[Ev
|
||||
floor = day_start if all_day else since
|
||||
if moving < floor or moving > end or moving.astimezone(UTC) in skip:
|
||||
continue
|
||||
out.append(Event(start=moving, title=title, place=place, all_day=all_day))
|
||||
out.append(
|
||||
Event(
|
||||
start=moving,
|
||||
title=title,
|
||||
place=place,
|
||||
all_day=all_day,
|
||||
routine=routine,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,10 @@ LABEL = "справка (указатели по сообщению, не выв
|
||||
VOWELS = "аеёиоуыэюя"
|
||||
KINDS = {"master": "мастер", "branch": "ветка"}
|
||||
CALENDAR = "🗓 календарь"
|
||||
CALENDAR_LIMIT = 15
|
||||
CALENDAR_LIMIT = 40
|
||||
"""Предохранитель на разовые события; повседневные повторы под него не идут."""
|
||||
CALENDAR_NAMES = 5
|
||||
"""Сколько названий показывать в свёрнутой строке повседневных повторов."""
|
||||
CALENDAR_ERROR_MAX = 90
|
||||
|
||||
|
||||
@@ -339,21 +342,42 @@ class Recall:
|
||||
return lines or [f"{CALENDAR}: ОШИБКА фиды не ответили"]
|
||||
|
||||
def calendar_window(self, found: Upcoming, days: int) -> list[str]:
|
||||
"""Окно ответивших фидов: заголовок с временем данных и события строками."""
|
||||
"""Окно ответивших фидов: разовые событиями, повседневный быт - одной строкой.
|
||||
|
||||
Разовых за неделю физически мало, и ровно они и есть сигнал: экзамен,
|
||||
встреча, приём. Их видно целиком; лимит тут - предохранитель от абсурда,
|
||||
а не способ уместиться. Ежедневные повторы считаются и называются, но
|
||||
своими строками разовые не вытесняют - иначе секция выглядит здоровой и
|
||||
при этом показывает двое суток вместо недели.
|
||||
"""
|
||||
stamp = f"{found.at:%Y-%m-%d %H:%M}"
|
||||
if not found.events:
|
||||
return [f"{CALENDAR} ({days} дней): событий нет, данные на {stamp}"]
|
||||
zone = ZoneInfo(self.tz)
|
||||
shown = found.events[:CALENDAR_LIMIT]
|
||||
lines = [
|
||||
f"{CALENDAR} ({days} дней), событий {len(found.events)}, данные на {stamp}:"
|
||||
]
|
||||
singles = [event for event in found.events if not event.routine]
|
||||
routine = [event for event in found.events if event.routine]
|
||||
shown = singles[:CALENDAR_LIMIT]
|
||||
head = (
|
||||
f"{CALENDAR} ({days} дней), разовых {len(singles)}, "
|
||||
f"повседневных {len(routine)}, данные на {stamp}:"
|
||||
)
|
||||
lines = [head]
|
||||
lines += [f" - {event_line(event, zone)}" for event in shown]
|
||||
if len(found.events) > len(shown):
|
||||
lines.append(f" … и ещё {len(found.events) - len(shown)} в окне")
|
||||
if len(singles) > len(shown):
|
||||
lines.append(f" … и ещё {len(singles) - len(shown)} разовых в окне")
|
||||
if routine:
|
||||
lines.append(
|
||||
f" - повседневных повторов: {len(routine)} ({routine_names(routine)})"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def routine_names(events: list[Event], *, limit: int = CALENDAR_NAMES) -> str:
|
||||
"""Уникальные названия бытовых повторов по первому появлению, до `limit`."""
|
||||
names = list(dict.fromkeys(event.title for event in events))
|
||||
return ", ".join(names[:limit]) + (" и др." if len(names) > limit else "")
|
||||
|
||||
|
||||
def event_line(event: Event, zone: ZoneInfo) -> str:
|
||||
"""Дата, начало, название и место - одной строкой в зоне агента."""
|
||||
start = event.start.astimezone(zone)
|
||||
|
||||
Reference in New Issue
Block a user