From b0a93dbc150ab32057a9c75c4842655fa7ab7c0a Mon Sep 17 00:00:00 2001 From: lachtan Date: Wed, 10 Jun 2026 10:54:21 +0200 Subject: [PATCH] rozsireni o predpovidani posilani --- skills/remind/SKILL.md | 3 + skills/remind/scripts/forecast.py | 117 +++++++++++++++++ skills/remind/scripts/random_times.py | 15 +++ skills/remind/scripts/remind_cli.py | 23 ++++ skills/remind/scripts/remind_send.py | 13 +- skills/remind/tests/test_forecast.py | 177 ++++++++++++++++++++++++++ 6 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 skills/remind/scripts/forecast.py create mode 100644 skills/remind/tests/test_forecast.py diff --git a/skills/remind/SKILL.md b/skills/remind/SKILL.md index 03c5e51..2b0f325 100644 --- a/skills/remind/SKILL.md +++ b/skills/remind/SKILL.md @@ -19,6 +19,7 @@ Reply to the user in their own language. | "on 2026-06-15 at 18:00" / "once at …" | `add --at "2026-06-15T18:00:00"` | | "randomly 2× between 08:00 and 20:00" | `add --random-times-per-day 2 --random-window 08:00-20:00` | | "what reminders arrived today / since when" | `delivered [--since YYYY-MM-DD]` | +| "what goes out today / tomorrow / this week" | `upcoming [--date YYYY-MM-DD \| --days N]` | | list all reminders | `list` | For the full flag reference of any command, run: @@ -47,6 +48,8 @@ An empty store prints `(no active reminders)`. **`delivered`** reads the `reminder_fires` table (delivered rows only, Prague local time). Defaults to today; `--since YYYY-MM-DD` widens the window. The agent never sees deliveries happen — this is the only window into them. +**`upcoming`** returns readable text: each scheduled fire as `YYYY-MM-DD HH:MM #id text (type)`, sorted by time. It shows the *plan* (computed from the schedules), not actual deliveries — use `delivered` for those. Defaults to the rest of today; `--date` shows one whole day, `--days N` the next N calendar days. An empty window prints `(nothing scheduled in this window)`. + **`remove`** is a soft delete. ## Editing reminders diff --git a/skills/remind/scripts/forecast.py b/skills/remind/scripts/forecast.py new file mode 100644 index 0000000..559612d --- /dev/null +++ b/skills/remind/scripts/forecast.py @@ -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 diff --git a/skills/remind/scripts/random_times.py b/skills/remind/scripts/random_times.py index 3351763..0772a71 100644 --- a/skills/remind/scripts/random_times.py +++ b/skills/remind/scripts/random_times.py @@ -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) diff --git a/skills/remind/scripts/remind_cli.py b/skills/remind/scripts/remind_cli.py index 845ab5a..b66cc9b 100755 --- a/skills/remind/scripts/remind_cli.py +++ b/skills/remind/scripts/remind_cli.py @@ -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) diff --git a/skills/remind/scripts/remind_send.py b/skills/remind/scripts/remind_send.py index bf8a5df..a28e83a 100644 --- a/skills/remind/scripts/remind_send.py +++ b/skills/remind/scripts/remind_send.py @@ -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: diff --git a/skills/remind/tests/test_forecast.py b/skills/remind/tests/test_forecast.py new file mode 100644 index 0000000..2415b07 --- /dev/null +++ b/skills/remind/tests/test_forecast.py @@ -0,0 +1,177 @@ +import sys +from datetime import datetime +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).parent.parent / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from db import get_db, init_db +from forecast import fires_in_window, format_upcoming, window_for + + +def _add_reminder(conn, text, enabled=1, deleted_at=None): + conn.execute( + "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at, deleted_at) " + "VALUES (?, ?, 'Europe/Prague', 'now', 'now', ?)", + (text, enabled, deleted_at), + ) + return conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + +@pytest.fixture +def conn(tmp_path): + db_path = tmp_path / "test.sqlite" + init_db(db_path) + connection = get_db(db_path) + yield connection + connection.close() + + +# --- window_for --------------------------------------------------------------- + +def test_window_default_is_rest_of_today(): + now = datetime(2026, 6, 10, 14, 30, 0) + start, end = window_for(now, None, None) + assert start == now + assert end == datetime(2026, 6, 11, 0, 0, 0) + + +def test_window_date_is_whole_day(): + start, end = window_for(datetime(2026, 6, 10, 14, 30), "2026-06-15", None) + assert start == datetime(2026, 6, 15, 0, 0, 0) + assert end == datetime(2026, 6, 16, 0, 0, 0) + + +def test_window_days_spans_n_calendar_days(): + now = datetime(2026, 6, 10, 14, 30, 0) + start, end = window_for(now, None, 3) + assert start == now + assert end == datetime(2026, 6, 13, 0, 0, 0) + + +def test_window_date_and_days_mutually_exclusive(): + with pytest.raises(ValueError): + window_for(datetime(2026, 6, 10), "2026-06-15", 3) + + +def test_window_days_must_be_positive(): + with pytest.raises(ValueError): + window_for(datetime(2026, 6, 10), None, 0) + + +# --- at ----------------------------------------------------------------------- + +def test_at_inside_window(conn): + rid = _add_reminder(conn, "at one") + conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T18:00:00")) + + fires = fires_in_window(conn, datetime(2026, 6, 10, 0, 0), datetime(2026, 6, 11, 0, 0)) + assert len(fires) == 1 + assert fires[0]["fire_time"] == datetime(2026, 6, 10, 18, 0) + assert fires[0]["schedule_type"] == "at" + + +def test_at_outside_window_excluded(conn): + rid = _add_reminder(conn, "at later") + conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-20T18:00:00")) + + fires = fires_in_window(conn, datetime(2026, 6, 10, 0, 0), datetime(2026, 6, 11, 0, 0)) + assert fires == [] + + +def test_at_end_is_exclusive(conn): + rid = _add_reminder(conn, "at edge") + conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-11T00:00:00")) + + fires = fires_in_window(conn, datetime(2026, 6, 10, 0, 0), datetime(2026, 6, 11, 0, 0)) + assert fires == [] + + +# --- cron --------------------------------------------------------------------- + +def test_cron_enumerates_each_day(conn): + rid = _add_reminder(conn, "daily 9am") + conn.execute("INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid, "0 9 * * *")) + + fires = fires_in_window(conn, datetime(2026, 6, 10, 0, 0), datetime(2026, 6, 13, 0, 0)) + times = [f["fire_time"] for f in fires] + assert times == [ + datetime(2026, 6, 10, 9, 0), + datetime(2026, 6, 11, 9, 0), + datetime(2026, 6, 12, 9, 0), + ] + + +def test_cron_includes_fire_exactly_at_start(conn): + rid = _add_reminder(conn, "at start") + conn.execute("INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid, "0 9 * * *")) + + fires = fires_in_window(conn, datetime(2026, 6, 10, 9, 0), datetime(2026, 6, 11, 0, 0)) + assert [f["fire_time"] for f in fires] == [datetime(2026, 6, 10, 9, 0)] + + +# --- random ------------------------------------------------------------------- + +def test_random_matches_compute_fire_times(conn): + rid = _add_reminder(conn, "random reminder") + conn.execute( + "INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end) VALUES (?, ?, ?, ?)", + (rid, 2, 540, 1260), + ) + + from random_times import compute_fire_times + from datetime import date + expected = compute_fire_times(date(2026, 6, 10), "random reminder", {"times_per_day": 2, "window": "09:00-21:00"}) + + fires = fires_in_window(conn, datetime(2026, 6, 10, 0, 0), datetime(2026, 6, 11, 0, 0)) + assert [f["fire_time"] for f in fires] == expected + + +def test_random_respects_days_filter(conn): + rid = _add_reminder(conn, "weekday only") + conn.execute( + "INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end, days_filter) " + "VALUES (?, 1, 540, 1260, '1-5')", + (rid,), + ) + + # 2026-06-13 is a Saturday, 2026-06-14 Sunday — both filtered out; 2026-06-15 Monday allowed. + weekend = fires_in_window(conn, datetime(2026, 6, 13, 0, 0), datetime(2026, 6, 15, 0, 0)) + assert weekend == [] + monday = fires_in_window(conn, datetime(2026, 6, 15, 0, 0), datetime(2026, 6, 16, 0, 0)) + assert len(monday) == 1 + + +# --- general ------------------------------------------------------------------ + +def test_disabled_and_deleted_excluded(conn): + rid_off = _add_reminder(conn, "disabled", enabled=0) + conn.execute("INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid_off, "0 9 * * *")) + rid_del = _add_reminder(conn, "deleted", deleted_at="now") + conn.execute("INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid_del, "0 9 * * *")) + + fires = fires_in_window(conn, datetime(2026, 6, 10, 0, 0), datetime(2026, 6, 11, 0, 0)) + assert fires == [] + + +def test_fires_sorted_across_types(conn): + rid_cron = _add_reminder(conn, "morning") + conn.execute("INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid_cron, "0 9 * * *")) + rid_at = _add_reminder(conn, "evening") + conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid_at, "2026-06-10T18:00:00")) + + fires = fires_in_window(conn, datetime(2026, 6, 10, 0, 0), datetime(2026, 6, 11, 0, 0)) + assert [f["text"] for f in fires] == ["morning", "evening"] + + +def test_format_empty_window(): + assert format_upcoming([]) == ["(nothing scheduled in this window)"] + + +def test_format_line_shape(): + lines = format_upcoming([ + {"fire_time": datetime(2026, 6, 10, 9, 0), "id": 1, "text": "call mom", "schedule_type": "cron"}, + ]) + assert lines == ["2026-06-10 09:00 #1 call mom (cron)"]