rozsireni o predpovidani posilani

This commit is contained in:
lachtan
2026-06-10 10:54:21 +02:00
parent 10801da03e
commit b0a93dbc15
6 changed files with 337 additions and 11 deletions

View File

@@ -0,0 +1,117 @@
"""Forecast which reminders will fire in a time window.
A pure projection of the schedule — answers "what goes out today / tomorrow /
this week". Shared by remind_cli.py. Unlike remind_send.py it applies no
tolerance and no reminder_fires dedup: `upcoming` shows the plan, `delivered`
shows what actually happened.
Times are Prague local (naive), matching how schedules are stored.
"""
from __future__ import annotations
import sys
from datetime import date, datetime, time, timedelta
from croniter import croniter
from random_times import compute_fire_times, random_cfg_from_row
def window_for(now: datetime, date_str: str | None, days: int | None) -> tuple[datetime, datetime]:
"""Resolve the [start, end) window from CLI args.
--date: the whole given day. --days N: now through midnight after N
calendar days (including today). Neither: the rest of today (= --days 1).
"""
if date_str is not None and days is not None:
raise ValueError("--date and --days are mutually exclusive")
if date_str is not None:
start = datetime.combine(date.fromisoformat(date_str), time.min)
return start, start + timedelta(days=1)
span = 1 if days is None else days
if span < 1:
raise ValueError(f"--days must be >= 1, got {span}")
end = datetime.combine(now.date(), time.min) + timedelta(days=span)
return now, end
def fires_in_window(conn, start: datetime, end: datetime) -> list[dict]:
"""All scheduled fires in [start, end) across active reminders, sorted by time."""
fires = _at_fires(conn, start, end) + _cron_fires(conn, start, end) + _random_fires(conn, start, end)
fires.sort(key=lambda f: (f["fire_time"], f["id"]))
return fires
def format_upcoming(fires: list[dict]) -> list[str]:
if not fires:
return ["(nothing scheduled in this window)"]
return [
f"{f['fire_time']:%Y-%m-%d %H:%M} #{f['id']} {f['text']} ({f['schedule_type']})"
for f in fires
]
def _at_fires(conn, start: datetime, end: datetime) -> list[dict]:
rows = conn.execute(
"""
SELECT r.id, r.text, sa.at_datetime
FROM reminders r
JOIN schedule_at sa ON sa.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
AND sa.at_datetime >= ? AND sa.at_datetime < ?
""",
(start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds")),
).fetchall()
return [
{"fire_time": datetime.fromisoformat(r["at_datetime"]), "id": r["id"], "text": r["text"], "schedule_type": "at"}
for r in rows
]
def _cron_fires(conn, start: datetime, end: datetime) -> list[dict]:
rows = conn.execute(
"""
SELECT r.id, r.text, sc.cron_expr
FROM reminders r
JOIN schedule_cron sc ON sc.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
fires = []
for row in rows:
it = croniter(row["cron_expr"], start - timedelta(seconds=1))
while True:
fire_time = it.get_next(datetime)
if fire_time >= end:
break
if fire_time >= start:
fires.append({"fire_time": fire_time, "id": row["id"], "text": row["text"], "schedule_type": "cron"})
return fires
def _random_fires(conn, start: datetime, end: datetime) -> list[dict]:
rows = conn.execute(
"""
SELECT r.id, r.text, sr.times_per_day, sr.window_start, sr.window_end,
sr.days_filter, sr.from_date, sr.until_date
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
fires = []
last_day = (end - timedelta(seconds=1)).date()
for row in rows:
cfg = random_cfg_from_row(row)
day = start.date()
while day <= last_day:
try:
day_fires = compute_fire_times(day, row["text"], cfg)
except ValueError as exc:
print(f"forecast: bad random config for {row['text']!r}: {exc}", file=sys.stderr)
break
for fire_time in day_fires:
if start <= fire_time < end:
fires.append({"fire_time": fire_time, "id": row["id"], "text": row["text"], "schedule_type": "random"})
day += timedelta(days=1)
return fires

View File

@@ -86,6 +86,21 @@ def minutes_to_hhmm(total: int) -> str:
return f"{total // 60:02d}:{total % 60:02d}"
def random_cfg_from_row(row) -> dict:
"""Build a compute_fire_times config from a schedule_random DB row."""
cfg = {
"times_per_day": row["times_per_day"],
"window": f"{minutes_to_hhmm(row['window_start'])}-{minutes_to_hhmm(row['window_end'])}",
}
if row["days_filter"]:
cfg["days"] = row["days_filter"]
if row["from_date"]:
cfg["from"] = row["from_date"]
if row["until_date"]:
cfg["until"] = row["until_date"]
return cfg
def _minute_to_time(total_minutes: int) -> time:
return time(total_minutes // 60, total_minutes % 60)

View File

@@ -21,6 +21,7 @@ from zoneinfo import ZoneInfo
from croniter import croniter
from db import get_db, init_db, log_operation
from forecast import fires_in_window, format_upcoming, window_for
from random_times import compute_fire_times, minutes_to_hhmm, parse_window
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
@@ -430,6 +431,23 @@ def cmd_delivered(args: argparse.Namespace) -> int:
conn.close()
def cmd_upcoming(args: argparse.Namespace) -> int:
"""List scheduled fires in a time window (the plan, not deliveries — see `delivered`)."""
_ensure_db()
conn = get_db(DB_PATH)
try:
now = datetime.now(PRAGUE).replace(tzinfo=None)
start, end = window_for(now, args.date, args.days)
for line in format_upcoming(fires_in_window(conn, start, end)):
print(line)
return 0
except ValueError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)")
sub = parser.add_subparsers(dest="command", required=True)
@@ -474,6 +492,10 @@ def main() -> None:
delivered_p = sub.add_parser("delivered", help="List reminders delivered to the user (default: today)")
delivered_p.add_argument("--since", metavar="YYYY-MM-DD", help="List deliveries on/after this date instead of today")
upcoming_p = sub.add_parser("upcoming", help="List scheduled fires in a window (default: rest of today)")
upcoming_p.add_argument("--date", metavar="YYYY-MM-DD", help="Show fires for this whole day")
upcoming_p.add_argument("--days", type=int, metavar="N", help="Show fires for the next N calendar days (incl. today)")
args = parser.parse_args()
dispatch = {
"list": cmd_list,
@@ -483,6 +505,7 @@ def main() -> None:
"enable": cmd_enable,
"disable": cmd_disable,
"delivered": cmd_delivered,
"upcoming": cmd_upcoming,
}
return dispatch[args.command](args)

View File

@@ -23,7 +23,7 @@ from zoneinfo import ZoneInfo
from croniter import croniter
from db import get_db, init_db, log_operation
from random_times import compute_fire_times, minutes_to_hhmm
from random_times import compute_fire_times, random_cfg_from_row
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
@@ -127,16 +127,7 @@ def _due_random(conn, now: datetime) -> list[dict]:
).fetchall()
due = []
for row in rows:
cfg = {
"times_per_day": row["times_per_day"],
"window": f"{minutes_to_hhmm(row['window_start'])}-{minutes_to_hhmm(row['window_end'])}",
}
if row["days_filter"]:
cfg["days"] = row["days_filter"]
if row["from_date"]:
cfg["from"] = row["from_date"]
if row["until_date"]:
cfg["until"] = row["until_date"]
cfg = random_cfg_from_row(row)
try:
fires = compute_fire_times(now.date(), row["text"], cfg)
except ValueError as exc: