# /remind skill — návrh přechodu z YAML na SQLite ## 1. Proč SQLite | Aspekt | YAML (současné) | SQLite (navrhované) | |--------|-----------------|---------------------| | Atomicita | tmp+rename, žádné transakce | `BEGIN` … `COMMIT` | | Query | Načíst celý soubor do paměti | SELECT s JOIN a indexy | | Dedup | Externí `.reminder_state.json` | Tabulka `reminder_fires` | | Datové typy | Vše string | INTEGER, TEXT ISO, CHECK | | Edit | Chybí (celý záznam se přepisuje) | UPDATE / DELETE per sloupec | | Testy | File-based, side-effects | `:memory:` databáze | | Audit | Žádný | `reminder_fires.status` + `error_message` | ## 2. Navrhované schéma ```sql PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; -- Hlavní entita ----------------------------------------------------------- CREATE TABLE reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, timezone TEXT NOT NULL DEFAULT 'Europe/Prague', created_at TEXT NOT NULL, -- ISO-8601 updated_at TEXT NOT NULL, -- ISO-8601 deleted_at TEXT -- soft-delete, NULL = aktivní ); -- One-time scheduly ------------------------------------------------------- CREATE TABLE schedule_at ( id INTEGER PRIMARY KEY AUTOINCREMENT, reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, at_datetime TEXT NOT NULL, -- ISO-8601 (lokální čas dle reminders.timezone) enabled INTEGER NOT NULL DEFAULT 1 ); -- Recurring cron scheduly ------------------------------------------------- CREATE TABLE schedule_cron ( id INTEGER PRIMARY KEY AUTOINCREMENT, reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, cron_expr TEXT NOT NULL, -- standardní cron, např. "0 9 * * 1-5" enabled INTEGER NOT NULL DEFAULT 1 ); -- Random scheduly --------------------------------------------------------- CREATE TABLE schedule_random ( id INTEGER PRIMARY KEY AUTOINCREMENT, reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, times_per_day INTEGER NOT NULL, window_start_min INTEGER NOT NULL, -- 0..1439 (minuty od půlnoci) window_end_min INTEGER NOT NULL, -- 0..1440 (výhradně horní mez) days_filter TEXT, -- např. "1-5", NULL = každý den from_date TEXT, -- YYYY-MM-DD, NULL = okamžitě until_date TEXT, -- YYYY-MM-DD, NULL = navždy enabled INTEGER NOT NULL DEFAULT 1, CHECK(times_per_day >= 1), CHECK(window_start_min >= 0 AND window_start_min < 1440), CHECK(window_end_min > 0 AND window_end_min <= 1440), CHECK(window_start_min < window_end_min) ); -- Audit / dedup / delivery log -------------------------------------------- CREATE TABLE reminder_fires ( id INTEGER PRIMARY KEY AUTOINCREMENT, reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, schedule_id INTEGER NOT NULL, -- ID v příslušné schedule_* tabulce schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at','cron','random')), fire_time TEXT NOT NULL, -- ISO-8601, plánovaný čas výstřelu delivered_at TEXT, -- ISO-8601, skutečný čas doručení status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','delivered','failed')), error_message TEXT ); -- Indexy ------------------------------------------------------------------ CREATE INDEX idx_reminders_text ON reminders(text); CREATE INDEX idx_fire_lookup ON reminder_fires( reminder_id, schedule_type, schedule_id, fire_time, status ); CREATE INDEX idx_at_datetime ON schedule_at(reminder_id, at_datetime); CREATE INDEX idx_cron_expr ON schedule_cron(reminder_id, cron_expr); ``` ## 3. Lepší datové typy oproti YAML | Pole (YAML) | SQLite sloupec | Proč lepší | |-------------|----------------|------------| | `times_per_day: "5"` (string v YAML) | `times_per_day INTEGER` | Nativní číselná validace, CHECK constraint | | `window: "09:00-21:00"` (string) | `window_start_min INTEGER`, `window_end_min INTEGER` | Umožňuje matematiku (`fire_minute BETWEEN 540 AND 1260`), sortable | | `at: "2026-06-10T10:00:00"` | `at_datetime TEXT` | Sice stále TEXT, ale ISO formát je porovnatelný a sortable; SQLite nemá nativní datetime | | `days: "1-5"` | `days_filter TEXT` | Zůstává TEXT — parsuje se až při běhu; alternativně normalizovat na `random_days(day_of_week INT)`, ale pro 1–5 položek to je overkill | | `from` / `until` | `from_date TEXT`, `until_date TEXT` | ISO date je sortable; pro query stačí `date <= '2026-06-10'` | **Poznámka k časům:** SQLite nemá nativní `DATETIME` typ. Doporučuji ukládat jako **TEXT v ISO-8601** (např. `2026-06-10T10:00:00+02:00`) místo Unix timestampu — je to čitelné, sortable a přímo použitelné s `datetime.fromisoformat()`. ## 4. Jednotlivé typy časů — proč 1:N a ne jedna tabulka Současný YAML model: ```yaml - text: "water the plants" cron_exprs: ["0 19 * * *"] random: {times_per_day: 2, window: "08:00-12:00"} ``` V DB to rozdělíme na **jeden řádek `reminders`** + **řádky v `schedule_cron` a `schedule_random`**. Důvody: - **Normalizace**: Každý schedule má svůj životní cyklus — jde zapnout/vypnout, editovat, mazat bez dotyku ostatních. - **Dedup**: `reminder_fires` odkazuje na konkrétní `schedule_id` + `schedule_type`. Víme přesně, který cron nebo random výstřel už byl doručen. - **Extensibility**: Přidání nového typu schedule = nová tabulka, není potřeba migrovat existující data. ## 5. Dává smysl ukládat cron jako cron string? **Ano.** - Cron je de facto standard, `croniter` ho umí parsovat i expandovat (`get_prev` / `get_next`). - Rozparsování na `cron_minute INT`, `cron_hour INT` atd. by ztratilo expresivitu (`*/15`, `L`, ranges, step values). - Ukládání jako cron string je kompaktní a čitelné. Random schedule **nelze** vyjádřit jako cron — je to vlastní algoritmus `compute_fire_times()`. Proto má samostatnou tabulku s parametry. ## 6. Dedup a state — z `.reminder_state.json` do DB Současný mechanismus: ```python key = hashlib.sha1(text.encode()).hexdigest()[:8] last = state.get(key) # "2026-06-10T09:20:00" ``` Problém: hash textu je hrubý — změna textu znamená nový key, stejný text = stejný key pro všechny scheduly. Nový mechanismus v SQLite: ```sql -- Před odesláním: SELECT 1 FROM reminder_fires WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = ? AND fire_time = ? AND status = 'delivered'; ``` - Přesná dedup **per schedule**, ne per text. - `status = 'failed'` umožňuje retry při příštím běhu. - `error_message` zachytí proč Telegram API selhalo. - `delivered_at` je audit trail. ## 7. Query pro remind_send.py (místo načítání celého YAML) ```sql -- Najít všechny due fires za posledních 60 sekund SELECT r.id AS reminder_id, r.text, 'at' AS schedule_type, sa.id AS schedule_id, sa.at_datetime AS fire_time FROM reminders r JOIN schedule_at sa ON sa.reminder_id = r.id WHERE r.enabled = 1 AND sa.enabled = 1 AND sa.at_datetime > datetime('now', '-60 seconds') AND sa.at_datetime <= datetime('now') AND NOT EXISTS ( SELECT 1 FROM reminder_fires rf WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime AND rf.status = 'delivered' ) UNION ALL -- Cron: vypočítat v Pythonu přes croniter, ale DB řekne které expr existují SELECT r.id, r.text, 'cron', sc.id, sc.cron_expr FROM reminders r JOIN schedule_cron sc ON sc.reminder_id = r.id WHERE r.enabled = 1 AND sc.enabled = 1; -- croniter.get_prev() se provede v Pythonu, pak se porovná s now-60s UNION ALL -- Random: všechny aktivní random scheduly SELECT r.id, r.text, 'random', sr.id, NULL FROM reminders r JOIN schedule_random sr ON sr.reminder_id = r.id WHERE r.enabled = 1 AND sr.enabled = 1 AND (sr.from_date IS NULL OR sr.from_date <= date('now')) AND (sr.until_date IS NULL OR sr.until_date >= date('now')); -- compute_fire_times(date.today(), ...) se provede v Pythonu ``` ## 8. CLI změny `remind_edit.py` zachová stejné CLI rozhraní, backend se změní: | Subcommand | Změna | |------------|-------| | `list` | SQL JOIN místo `yaml.safe_load` + JSON dump | | `add` | `INSERT INTO reminders` + `INSERT INTO schedule_*` v jedné transakci | | `remove --keyword` | `SELECT id FROM reminders WHERE text LIKE '%keyword%'` → `DELETE` nebo `UPDATE deleted_at` | | **nové** `edit --keyword` | `UPDATE reminders.text` nebo přidání/odebrání schedulů | | **nové** `enable` / `disable` | `UPDATE reminders SET enabled = 0/1` | ## 9. Další věci k uvážení ### 9.1 Timezone - Všechny `at_datetime` a `fire_time` by měly být **aware** (s offsetem `+02:00`) nebo explicitně v `reminders.timezone`. - Cron výrazy jsou vždy v lokální čase — `croniter` běží nad `datetime.now(TZ)`. - Doporučení: ukládat jako **TEXT s offsetem** (`2026-06-10T10:00:00+02:00`), při query převádět v Pythonu. ### 9.2 WAL mode ```sql PRAGMA journal_mode = WAL; ``` Umožní čtení během zápisu. Pro remind_send.py (každou minutu SELECT) + remind_edit.py (občasný INSERT/UPDATE) je to kritické. ### 9.3 Schema versioning ```sql CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY); INSERT INTO _schema_version VALUES (1); ``` Při startu skriptu zkontrolovat verzi a spustit migrace. ### 9.4 Testování - SQLite podporuje `:memory:` databázi — testy mohou běžet bez file I/O. - `remind_edit.py` dostane parametr `--db PATH` (default `workspace/db/reminders.sqlite`). ### 9.5 Migrace z YAML Jednorázový skript: 1. Načíst `reminder.yaml` 2. `BEGIN TRANSACTION` 3. Pro každý reminder: `INSERT INTO reminders` → získat `lastrowid` 4. Podle polí `at` / `at_times` / `cron_exprs` / `random` vložit do příslušných schedule tabulek 5. `COMMIT` 6. Přejmenovat `reminder.yaml` → `reminder.yaml.bak` ### 9.6 Soft delete vs hard delete - `deleted_at TEXT` místo `DELETE FROM reminders` — zachová historii a umožní "undo". - `remind_edit.py remove` by default nastaví `deleted_at`, `--hard` by provedl skutečný DELETE. ### 9.7 FTS5 (volitelně) Pokud bude >100 reminderů, `CREATE VIRTUAL TABLE reminders_fts USING fts5(text)` urychlí fulltext vyhledávání pro `remove --keyword`. ### 9.8 Konfigurace cesty k DB ```python DEFAULT_DB = Path(__file__).resolve().parent.parent.parent.parent / "db" / "reminders.sqlite" ``` Podle pravidel v AGENTS.md: *Always store SQLite databases under `db/*.sqlite`*. ## 10. Shrnutí rozhodnutí | Otázka | Rozhodnutí | |--------|------------| | Ukládat cron jako string? | **Ano** — standard, expresivní, croniter to zvládne. | | Random do cron stringu? | **Ne** — random je vlastní algoritmus, ukládat parametry. | | Jedna tabulka vs schedule tabulky? | **3 schedule tabulky** (at, cron, random) — 1:N vztah. | | Dedup externě nebo v DB? | **V DB** — `reminder_fires` per schedule. | | Časy jako TEXT nebo INTEGER? | **TEXT ISO-8601** — čitelné, sortable, Python-compatible. | | Window jako string nebo minuty? | **INTEGER minuty** — umožňuje SQL matematiku. | | Hard delete nebo soft delete? | **Soft delete** (`deleted_at`) — audit trail. | | Transakce? | **Ano** — každý `add` / `remove` / `edit` v `BEGIN…COMMIT`. |