rozsireni o predpovidani posilani
This commit is contained in:
@@ -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"` |
|
| "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` |
|
| "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 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` |
|
| list all reminders | `list` |
|
||||||
|
|
||||||
For the full flag reference of any command, run:
|
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.
|
**`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.
|
**`remove`** is a soft delete.
|
||||||
|
|
||||||
## Editing reminders
|
## Editing reminders
|
||||||
|
|||||||
117
skills/remind/scripts/forecast.py
Normal file
117
skills/remind/scripts/forecast.py
Normal 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
|
||||||
@@ -86,6 +86,21 @@ def minutes_to_hhmm(total: int) -> str:
|
|||||||
return f"{total // 60:02d}:{total % 60:02d}"
|
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:
|
def _minute_to_time(total_minutes: int) -> time:
|
||||||
return time(total_minutes // 60, total_minutes % 60)
|
return time(total_minutes // 60, total_minutes % 60)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
from db import get_db, init_db, log_operation
|
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
|
from random_times import compute_fire_times, minutes_to_hhmm, parse_window
|
||||||
|
|
||||||
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
|
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
@@ -430,6 +431,23 @@ def cmd_delivered(args: argparse.Namespace) -> int:
|
|||||||
conn.close()
|
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:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)")
|
parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)")
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
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 = 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")
|
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()
|
args = parser.parse_args()
|
||||||
dispatch = {
|
dispatch = {
|
||||||
"list": cmd_list,
|
"list": cmd_list,
|
||||||
@@ -483,6 +505,7 @@ def main() -> None:
|
|||||||
"enable": cmd_enable,
|
"enable": cmd_enable,
|
||||||
"disable": cmd_disable,
|
"disable": cmd_disable,
|
||||||
"delivered": cmd_delivered,
|
"delivered": cmd_delivered,
|
||||||
|
"upcoming": cmd_upcoming,
|
||||||
}
|
}
|
||||||
return dispatch[args.command](args)
|
return dispatch[args.command](args)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
from db import get_db, init_db, log_operation
|
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
|
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
|
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
|
||||||
@@ -127,16 +127,7 @@ def _due_random(conn, now: datetime) -> list[dict]:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
due = []
|
due = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
cfg = {
|
cfg = random_cfg_from_row(row)
|
||||||
"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"]
|
|
||||||
try:
|
try:
|
||||||
fires = compute_fire_times(now.date(), row["text"], cfg)
|
fires = compute_fire_times(now.date(), row["text"], cfg)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
177
skills/remind/tests/test_forecast.py
Normal file
177
skills/remind/tests/test_forecast.py
Normal file
@@ -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)"]
|
||||||
Reference in New Issue
Block a user