diff --git a/.env.example b/.env.example index 5d82933..51e12a5 100644 --- a/.env.example +++ b/.env.example @@ -74,17 +74,17 @@ SCAN_RUN_ON_START=1 # — Distribusi profit-share (service distribute) — # Jam (lokal, TZ) run. Jadwal MINGGUAN (V37): tiap DISTRIBUTE_WEEKDAY (nama -# `sat`/`mon`.. atau 0-6; Senin=0, default `sat`) jam DISTRIBUTE_HOUR, plus satu -# one-off DISTRIBUTE_FIRST_RUN (YYYY-MM-DD; default baked `2026-08-17`). Contoh -# produksi: +# `sat`/`mon`.. atau 0-6; Senin=0, CSV utk beberapa hari: `wed,sat` = Rabu & +# Sabtu) jam DISTRIBUTE_HOUR, plus satu one-off DISTRIBUTE_FIRST_RUN +# (YYYY-MM-DD; default baked `2026-08-17`). Contoh produksi: # DISTRIBUTE_FIRST_RUN=2026-08-17 # putaran pertama Senin 17 Agt -# DISTRIBUTE_WEEKDAY=sat # lalu tiap Sabtu +# DISTRIBUTE_WEEKDAY=wed,sat # lalu tiap Rabu & Sabtu # `VEX_BP_PRIVATE_KEY` TIDAK lagi lewat interpolasi .env: di stack ia dibaca # dari file `.env` yang di-bind dari NFS # (`/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env`, # dibaca `config.py` via load_dotenv). Di luar stack, set key di `.env` lokal. DISTRIBUTE_HOUR=10 -DISTRIBUTE_WEEKDAY=sat +DISTRIBUTE_WEEKDAY=wed,sat DISTRIBUTE_FIRST_RUN=2026-08-17 DISTRIBUTE_MAX_ATTEMPTS=3 # Kill-switch: FALSE (default) = payout off; set TRUE untuk mengaktifkan diff --git a/AGENTS.md b/AGENTS.md index cad141a..ed2ec2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,11 +11,11 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list. - Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container: `VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py` -- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` weekly at `DISTRIBUTE_WEEKDAY` default `sat` + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`. +- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5001`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` twice-weekly at `DISTRIBUTE_WEEKDAY` default `wed,sat` (CSV, multi-day V44) + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`. - Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Vote bands (V40/V42): BARU (new first-time voters, vote < 3 days, own table on top, never paid) → main list: KADALUARSA (28–31 days, pinned amber rows at the top with `VOTE ULANG` badge + legend) then VALID (the paged 50/page list, `staked DESC`, live owner search via `/api/search`); voters older than 31 days are stored by the scan but hidden. Since V42, **REVOTE** (returning voters, `voter_first_seen.first_seen_at ≤ now−3d`) are VALID immediately — they join the main list tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`), no separate band, no maturity wait; since V43 the mint tag shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS` (default 1 day) — after that they're plain VALID (still paid); VALID rows within `VEX_WARN_DAYS` (default 3) of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge (legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH`); only genuinely new voters pass the 3-day maturity in BARU. Data freshness comes from the daily scan run — the dashboard never scans. -- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `DISTRIBUTE_FIRST_RUN` (default baked `2026-08-17`), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. +- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY` (CSV multi-hari V44, default `wed,sat`), `DISTRIBUTE_FIRST_RUN` (default baked `2026-08-17`), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. - Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`. -- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37) is pure calendar logic — weekly schedule boundary + one-off first-run. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. +- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37/V44) is pure calendar logic — weekly schedule boundary (multi-day CSV) + one-off first-run. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. ## Key facts @@ -48,7 +48,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd. - `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard. - `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar. -- `distribute_loop.py` — scheduler mingguan dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `sat`) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi). +- `distribute_loop.py` — scheduler dua-kali-minggu dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `wed,sat`, CSV multi-hari V44) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi). - `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context. - `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan + distribute + claim); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`; `distribute` & `claim` bawa `VEX_BP_PRIVATE_KEY` via bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` (dibaca `config.py` `load_dotenv`; ⊥ interpolasi). `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`). - `DESIGN.md` — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono). diff --git a/SPEC.md b/SPEC.md index 963d235..4fb57ac 100644 --- a/SPEC.md +++ b/SPEC.md @@ -115,8 +115,9 @@ V33: reward = max(saldo liquid setelah − sebelum, 0) saat klaim (settle delay V34: siklus klaim selesai ⊥ bila klaim tercatat (`claim_runs`) DAN fee terkirim; fee `pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi V35: kill-switch distribusi `DISTRIBUTE_ENABLED` default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; `--dry-run` tetap menampilkan rencana (read-only); nilai `true`/`1`/`yes` → normal V36: notifikasi klaim reward (sukses `notify_claim` & gagal `notify_claim_failure`) → kanal INTERNAL saja, ⊥ pernah ke KOMUNITAS (V30); fee gagal ⊥ spam — satu notif per klaim (saat transisi ke `failed`) -V37: jadwal distribusi MINGGUAN (⊥ harian): `distribute_loop` menunggu `DISTRIBUTE_WEEKDAY` (nama `mon`..`sun` atau 0-6; default `sat`) jam `DISTRIBUTE_HOUR` (lokal TZ); plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`; default baked `2026-08-17`) yang diambil bila lebih dekat dari mingguan; setelah lewat (atau ⊥ diset) → hanya mingguan; batas dihitung ulang tiap iterasi loop (setelah run → jadwal berikutnya); payout di-tunda bila `DISTRIBUTE_ENABLED=false` (V35) — jadwal tetap maju, run jadi no-op; `distribute_loop.main` baca jadwal via `config` (⊥ `os.getenv` sendiri) agar konsisten dgn dashboard V38 -V38: dashboard index menampilkan banner BERITA jadwal distribusi berikutnya (`DISTRIBUSI BERIKUTNYA` + tanggal id-ID `SENIN · 17 AGUSTUS 2026 · 10:00 WIB`); baris berulang `SETIAP SABTU · 10:00` HANYA utk putaran mingguan — ⊥ tampil saat event berikutnya = one-off first-run (tanggal launch ≠ hari mingguan → tak konsisten; `_is_first_run_next`); setelah launch lewat → baris muncul lagi; logika boundary MIRROR `distribute_loop.next_boundary` (⊥ impor lintas-image, V31; drift dijaga oracle test_dashboard); TZ lokal utk konsistensi dgn jadwal; stack: web service juga menerima `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`/`TZ` (interpolasi .env) +V37: jadwal distribusi MINGGUAN (⊥ harian): `distribute_loop` menunggu `DISTRIBUTE_WEEKDAY` — SATU hari (`sat`) atau CSV beberapa hari (`wed,sat`), nama `mon`..`sun` atau 0-6 (V44: multi-hari, event terdekat di antara hari-hari terdaftar) — jam `DISTRIBUTE_HOUR` (lokal TZ); plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`; default baked `2026-08-17`) yang diambil bila lebih dekat dari mingguan; setelah lewat (atau ⊥ diset) → hanya mingguan; batas dihitung ulang tiap iterasi loop (setelah run → jadwal berikutnya); payout di-tunda bila `DISTRIBUTE_ENABLED=false` (V35) — jadwal tetap maju, run jadi no-op; `distribute_loop.main` baca jadwal via `config` (⊥ `os.getenv` sendiri) agar konsisten dgn dashboard V38 +V38: dashboard index menampilkan banner BERITA jadwal distribusi berikutnya (`DISTRIBUSI BERIKUTNYA` + tanggal id-ID `SENIN · 17 AGUSTUS 2026 · 10:00 WIB`); baris berulang `SETIAP RABU & SABTU · 10:00` (V44: hari di-join ` & ` sesuai CSV `DISTRIBUTE_WEEKDAY`) HANYA utk putaran mingguan — ⊥ tampil saat event berikutnya = one-off first-run (tanggal launch ≠ hari mingguan → tak konsisten; `_is_first_run_next`); setelah launch lewat → baris muncul lagi; logika boundary MIRROR `distribute_loop.next_boundary` (⊥ impor lintas-image, V31; drift dijaga oracle test_dashboard); TZ lokal utk konsistensi dgn jadwal; stack: web service juga menerima `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`/`TZ` (interpolasi .env) +V44: jadwal distribusi MULTI-HARI (Rabu & Sabtu): `DISTRIBUTE_WEEKDAY` menerima CSV (`wed,sat` default; nilai tunggal tetap valid) → `parse_weekdays` (list int unik terurut, mirror distribute_loop/dashboard); `next_boundary(now, hour, weekdays, first_run)` pilih event terdekat di antara hari-hari terdaftar, hormati gating first_run (hari sebelum launch dilewati); dashboard `_recurrence_label` = `SETIAP {hari & hari} · HH:00`; default baked `wed,sat` (config/compose/.env.example); oracle test_distribute_loop (multi-hari) + test_dashboard (drift-guard list + skalar) ## §T — Tasks id|status|task|cites @@ -164,6 +165,7 @@ T41|x|kill-switch distribusi: `DISTRIBUTE_ENABLED` (config default false) + gate T42|x|Telegram dua kanal: rename `TELEGRAM_CHAT_IDS` → `TELEGRAM_COMMUNITY_CHAT_IDS` + tambah `TELEGRAM_INTERNAL_CHAT_IDS` (config), `telegram.py` `send_text(chat_ids=None)`=komunitas + `send_internal`; notify_start/finish → komunitas brief + internal detail; notify_failure/notify_claim/notify_claim_failure → internal saja; oracle V30 (dua kanal) + V36 (klaim ⊥ komunitas)|V30,V36,I.env T43|x|jadwal mingguan: `distribute_loop.py` `next_boundary` = {one-off `DISTRIBUTE_FIRST_RUN` @ jam (jika depan)} ∪ {mingguan `DISTRIBUTE_WEEKDAY` @ jam ≥ tanggal launch; Sabtu sebelum launch dilewati} + parser hari/nama + config.py (`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`) + env compose + .env.example + oracle `test_distribute_loop.py`|V37,I.env T44|x|dashboard banner BERITA jadwal distribusi berikutnya: `dashboard.py` mirror `_next_boundary` (⊥ impor lintas-image, V31) + `_next_schedule`/`_fmt_schedule` (id-ID) + `_recurrence_label` (baris berulang hanya utk mingguan, ⊥ saat first-run, `_is_first_run_next`); section `.news` di `index.html` + CSS; env compose web += `TZ`/`DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`; oracle V38 (banner + drift-guard vs distribute_loop)|V38,V37,I.env +T49|x|jadwal multi-hari Rabu & Sabtu (V44): `config.py` default `DISTRIBUTE_WEEKDAY=wed,sat`; `distribute_loop.py` += `parse_weekdays` (CSV → list int unik) + `next_boundary` terima list (skalar kompatibel, normalisasi); `dashboard.py` mirror `_parse_weekdays` + `_recurrence_label` `SETIAP RABU & SABTU · 10:00` (`&` via `|safe`); compose + .env.example `wed,sat`; docs V37/V38 amend + V44 + T49; oracle test_distribute_loop (multi-hari) + test_dashboard (drift list/skalar + label); image web+dist rebuild & push|V44,V37,V38,I.env T45|x|band umur vote V40: scan simpan semua umur (hapus `_is_stale`), `config` += `VEX_MATURITY_DAYS`/`VEX_EXPIRED_DAYS`, dashboard = BARU (bagian sendiri) + daftar utama (kadaluarsa menyatu di atas, sorot amber+badge+legenda, lalu VALID pager) + search bertag + stats/count/rank scoped, distribusi filter matang+segar (`_eligible_cutoffs`), env/.env.example + docs, oracle test_get_voters/test_dashboard/test_distribute|V40,V13,V11,V15,I.env T46|x|BARU vs REVOTE (V41): `db.py` += tabel `voter_first_seen` + `ensure_first_seen_schema`/`record_first_seen` (INSERT OR IGNORE/IGNORE, PK owner, append-only); scan `persist` mencatat first_seen; dashboard `_edge_rows`/`_stats`/`_search_rows` klasifikasi `first_seen > new_cut` → BARU, ≤ → REVOTE (group enum += `revote`), bagian REVOTE sendiri di bawah BARU + tag CSS `--mint`; docs + oracle test_get_voters/test_dashboard/test_mariadb|V41,V7,V15,V40 T47|x|REVOTE = VALID langsung (V42): `distribute.py` `run_distribution` `ensure_first_seen_schema` + query LEFT JOIN `voter_first_seen` → `last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE)`; dashboard `_edge_rows` → `(new_rows, expired_rows)` (REVOTE ⊖ BARU, masuk daftar utama), `_db_rows`/`_count` jendela sama + flag revote (elemen ke-8) + tag mint `REVOTE` di baris valid, `_stats` partisi n_new+n_revote+n_expired+n_valid == total, `_search_rows` group `revote` di jendela BARU & VALID; template ⊖ bagian REVOTE + legenda mint (`?v=13`); docs + oracle test_distribute/test_dashboard|V42,V40,V41,V15,I.db,I.web diff --git a/config.py b/config.py index 80f07bd..64ac27c 100644 --- a/config.py +++ b/config.py @@ -82,13 +82,13 @@ DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3')) # `true`. `--dry-run` tetap boleh jalan (read-only, ⊥ tanda tangan/tulis). DISTRIBUTE_ENABLED = os.getenv( 'DISTRIBUTE_ENABLED', 'false').lower() in ('1', 'true', 'yes') -# Jadwal mingguan (V37): hari `DISTRIBUTE_WEEKDAY` (nama `sat`/`mon`.. atau -# 0-6, default `sat`) jam `DISTRIBUTE_HOUR`; plus satu one-off +# Jadwal distribusi (V37/V44): hari `DISTRIBUTE_WEEKDAY` — satu nama (`sat`) atau +# CSV beberapa hari (`wed,sat`), jam `DISTRIBUTE_HOUR`; plus satu one-off # `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`). Dipakai distribute_loop (via config, # konsisten dgn dashboard V38). Default baked = launch pertama 2026-08-17 -# (Senin) 10:00; env tetap override. Setelah tanggal lewat → otomatis hanya -# mingguan (batas skip event masa lalu). -DISTRIBUTE_WEEKDAY = os.getenv('DISTRIBUTE_WEEKDAY', 'sat') +# (Senin) 10:00 lalu tiap Rabu & Sabtu; env tetap override. Setelah tanggal +# lewat → otomatis hanya mingguan (batas skip event masa lalu). +DISTRIBUTE_WEEKDAY = os.getenv('DISTRIBUTE_WEEKDAY', 'wed,sat') DISTRIBUTE_FIRST_RUN = os.getenv('DISTRIBUTE_FIRST_RUN', '2026-08-17') # ————————————————— Notifikasi Telegram (V30/V36) ————————————————— diff --git a/dashboard.py b/dashboard.py index f1b7743..6966d25 100644 --- a/dashboard.py +++ b/dashboard.py @@ -102,6 +102,18 @@ def _parse_weekday(value): raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}') +def _parse_weekdays(value): + """Satu hari (`sat`) atau CSV (`wed,sat`) → daftar weekday unik terurut + (int, Senin=0). Mirror `distribute_loop.parse_weekdays` (V44).""" + if not value: + raise ValueError('DISTRIBUTE_WEEKDAY kosong') + days = sorted({_parse_weekday(d) + for d in str(value).split(',') if d.strip()}) + if not days: + raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}') + return days + + def _parse_first_run(value): """`YYYY-MM-DD` → date; kosong → None (⊥ one-off).""" if not value: @@ -109,29 +121,34 @@ def _parse_first_run(value): return datetime.strptime(str(value).strip(), '%Y-%m-%d').date() -def _next_boundary(now, hour, weekday, first_run=None): - """Mirror `distribute_loop.next_boundary`: mingguan + one-off, Sabtu - sebelum launch dilewati; pilih event terdekat > `now`.""" - days_ahead = (weekday - now.weekday()) % 7 - weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0) - + timedelta(days=days_ahead)) - if weekly <= now: - weekly += timedelta(days=7) - if first_run is None: - return weekly - start = datetime(first_run.year, first_run.month, first_run.day, hour=hour) +def _next_boundary(now, hour, weekdays, first_run=None): + """Mirror `distribute_loop.next_boundary`: tiap hari di `weekdays` jam + `hour` + one-off `first_run` (yang sebelum launch dilewati); pilih event + terdekat > `now`.""" + if isinstance(weekdays, int): + weekdays = [weekdays] + start = None + if first_run is not None: + start = datetime(first_run.year, first_run.month, first_run.day, + hour=hour) candidates = [] - if start >= now: + if start is not None and start >= now: candidates.append(start) - if weekly >= start: - candidates.append(weekly) + for weekday in weekdays: + days_ahead = (weekday - now.weekday()) % 7 + weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0) + + timedelta(days=days_ahead)) + if weekly <= now: + weekly += timedelta(days=7) + if start is None or weekly >= start: + candidates.append(weekly) return min(candidates) def _next_schedule(now=None): """Run distribusi berikutnya (local TZ). `now` bisa di-inject utk oracle.""" return _next_boundary(now or datetime.now(), DISTRIBUTE_HOUR, - _parse_weekday(DISTRIBUTE_WEEKDAY), + _parse_weekdays(DISTRIBUTE_WEEKDAY), _parse_first_run(DISTRIBUTE_FIRST_RUN)) @@ -144,13 +161,14 @@ def _fmt_schedule(dt): def _recurrence_label(now=None): - """Baris berulang: `SETIAP SABTU · 10:00`. ⊥ tampil saat event berikutnya - = one-off first-run (tanggal launch ≠ hari mingguan → tak konsisten); - baris hanya utk putaran mingguan (muncul lagi setelah launch lewat).""" + """Baris berulang: `SETIAP RABU & SABTU · 10:00`. ⊥ tampil saat event + berikutnya = one-off first-run (tanggal launch ≠ hari mingguan → tak + konsisten); baris hanya utk putaran mingguan (muncul lagi setelah launch + lewat).""" if _is_first_run_next(now): return None - return (f'SETIAP {_SCHED_DAYS[_parse_weekday(DISTRIBUTE_WEEKDAY)]}' - f' · {DISTRIBUTE_HOUR:02d}:00') + hari = ' & '.join(_SCHED_DAYS[d] for d in _parse_weekdays(DISTRIBUTE_WEEKDAY)) + return f'SETIAP {hari} · {DISTRIBUTE_HOUR:02d}:00' def _is_first_run_next(now=None): diff --git a/distribute_loop.py b/distribute_loop.py index e532980..3083a4c 100644 --- a/distribute_loop.py +++ b/distribute_loop.py @@ -1,9 +1,10 @@ -"""Jadwal distribusi mingguan: jalankan distribute.py pada hari `DISTRIBUTE_WEEKDAY` -jam `DISTRIBUTE_HOUR`, plus satu one-off `DISTRIBUTE_FIRST_RUN` (YYYY-MM-DD, -default baked `2026-08-17` via config). +"""Jadwal distribusi: jalankan distribute.py pada hari `DISTRIBUTE_WEEKDAY` +(satu atau CSV, mis. `wed,sat`) jam `DISTRIBUTE_HOUR`, plus satu one-off +`DISTRIBUTE_FIRST_RUN` (YYYY-MM-DD, default baked `2026-08-17` via config). -Contoh produksi: `DISTRIBUTE_FIRST_RUN=2026-08-17` (Senin) lalu `DISTRIBUTE_WEEKDAY=sat` -→ putaran pertama Senin 17 Agt 10:00, berikutnya tiap Sabtu 10:00. +Contoh produksi: `DISTRIBUTE_FIRST_RUN=2026-08-17` (Senin) lalu +`DISTRIBUTE_WEEKDAY=wed,sat` → putaran pertama Senin 17 Agt 10:00, berikutnya +tiap Rabu & Sabtu 10:00. Menunggu hingga batas terjadwal berikutnya (waktu lokal via `TZ`) lalu memanggil `distribute.main()`. Loop tak pernah keluar — service dijaga hidup oleh @@ -43,6 +44,17 @@ def parse_weekday(value): raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}') +def parse_weekdays(value): + """Satu hari (`sat`) atau CSV beberapa hari (`wed,sat`) → daftar weekday + (int, Senin=0) unik terurut. V44: jadwal bisa multi-hari per minggu.""" + if not value: + raise ValueError('DISTRIBUTE_WEEKDAY kosong') + days = sorted({parse_weekday(d) for d in str(value).split(',') if d.strip()}) + if not days: + raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}') + return days + + def parse_first_run(value): """`YYYY-MM-DD` → date; kosong → None (⊥ one-off).""" if not value: @@ -50,26 +62,30 @@ def parse_first_run(value): return datetime.strptime(str(value).strip(), '%Y-%m-%d').date() -def next_boundary(now, hour, weekday, first_run=None): +def next_boundary(now, hour, weekdays, first_run=None): """datetime berikutnya utk distribusi. - Mingguan: `weekday` jam `hour`. Bila `first_run` diset, distribusi ⊥ jalan - sebelum tanggal itu — Sabtu (atau hari mingguan) yang jatuh sebelum launch - dilewati; event = {one-off `first_run` (jika masih depan)} ∪ {mingguan ≥ - tanggal `first_run`}; pilih yang terdekat > `now`.""" - days_ahead = (weekday - now.weekday()) % 7 - weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0) - + timedelta(days=days_ahead)) - if weekly <= now: - weekly += timedelta(days=7) - if first_run is None: - return weekly - start = datetime(first_run.year, first_run.month, first_run.day, hour=hour) + Mingguan: tiap hari di `weekdays` (list int) jam `hour`. Bila `first_run` + diset, distribusi ⊥ jalan sebelum tanggal itu — hari-hari mingguan yang + jatuh sebelum launch dilewati; event = {one-off `first_run` (jika masih + depan)} ∪ {mingguan ≥ tanggal `first_run`}; pilih yang terdekat > `now`.""" + if isinstance(weekdays, int): + weekdays = [weekdays] + start = None + if first_run is not None: + start = datetime(first_run.year, first_run.month, first_run.day, + hour=hour) candidates = [] - if start >= now: + if start is not None and start >= now: candidates.append(start) - if weekly >= start: - candidates.append(weekly) + for weekday in weekdays: + days_ahead = (weekday - now.weekday()) % 7 + weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0) + + timedelta(days=days_ahead)) + if weekly <= now: + weekly += timedelta(days=7) + if start is None or weekly >= start: + candidates.append(weekly) return min(candidates) @@ -97,15 +113,16 @@ def main(): hour = config.DISTRIBUTE_HOUR if not 0 <= hour <= 23: raise ValueError(f'DISTRIBUTE_HOUR tak valid: {hour!r}') - weekday = parse_weekday(config.DISTRIBUTE_WEEKDAY) + weekdays = parse_weekdays(config.DISTRIBUTE_WEEKDAY) first_run = parse_first_run(config.DISTRIBUTE_FIRST_RUN) - jadwal = (f'jam {hour} tiap hari {list(WEEKDAYS)[weekday]} (waktu lokal)' + hari = ' & '.join(list(WEEKDAYS)[d] for d in weekdays) + jadwal = (f'jam {hour} tiap hari {hari} (waktu lokal)' + (f', one-off {first_run.isoformat()}' if first_run else '')) print(f'Jadwal distribusi: {jadwal}') _wait_db() while True: now = datetime.now() - target = next_boundary(now, hour, weekday, first_run) + target = next_boundary(now, hour, weekdays, first_run) wait = (target - now).total_seconds() print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam ' f'sampai {target.isoformat(timespec="seconds")}', flush=True) diff --git a/docker-compose.yml b/docker-compose.yml index 2b61f55..65b6626 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ services: DASH_LIQUID_TTL: "60" TZ: ${TZ:-Asia/Jakarta} DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10} - DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-sat} + DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-wed,sat} DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17} ports: - published: 5001 @@ -108,7 +108,7 @@ services: VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid} TZ: ${TZ:-Asia/Jakarta} DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10} - DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-sat} + DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-wed,sat} DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17} DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3} DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false} diff --git a/templates/index.html b/templates/index.html index 781dc11..baecda1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -28,7 +28,7 @@
{{ next_schedule }}
{% if schedule_recur %} -{{ schedule_recur }}
+{{ schedule_recur|safe }}
{% endif %} diff --git a/test_dashboard.py b/test_dashboard.py index dbd73b0..b37f4ba 100644 --- a/test_dashboard.py +++ b/test_dashboard.py @@ -181,8 +181,8 @@ def main(): check('V38 judul DISTRIBUSI BERIKUTNYA', 'DISTRIBUSI BERIKUTNYA' in html) check('V38 tanggal id-ID (Senin 17 Agt)', 'SENIN · 17 AGUSTUS 2026 · 10:00' in html) - check('V38 baris berulang TAK tampil saat first-run (17 Agt ≠ Sabtu)', - 'SETIAP SABTU · 10:00' not in html) + check('V38 baris berulang TAK tampil saat first-run (17 Agt ≠ Rabu/Sabtu)', + 'SETIAP RABU & SABTU · 10:00' not in html) check('V38 _is_first_run_next sebelum launch = True', dashboard._is_first_run_next(datetime(2026, 8, 6)) is True) check('V38 _is_first_run_next setelah launch = False', @@ -191,13 +191,13 @@ def main(): dashboard._next_schedule = lambda now=None: datetime(2026, 8, 22, 10, 0) html = client.get('/').get_data(as_text=True) check('V38 baris berulang tampil utk putaran mingguan', - 'SETIAP SABTU · 10:00' in html) + 'SETIAP RABU & SABTU · 10:00' in html) # drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor) import distribute_loop drift_dts = [datetime(2026, 8, 6, 9, 0), datetime(2026, 8, 17, 11, 0), - datetime(2026, 8, 22, 9, 0), datetime(2026, 8, 22, 11, 0), - datetime(2026, 8, 15, 9, 0)] + datetime(2026, 8, 19, 9, 0), datetime(2026, 8, 22, 11, 0), + datetime(2026, 8, 12, 11, 0), datetime(2026, 8, 15, 9, 0)] fr = dashboard._parse_first_run('2026-08-17') check('V38 drift-guard one-off = distribute_loop', all(dashboard._next_boundary(n, 10, 5, fr) @@ -207,6 +207,17 @@ def main(): all(dashboard._next_boundary(n, 10, 5) == distribute_loop.next_boundary(n, 10, 5) for n in drift_dts)) + check('V44 drift-guard multi-hari wed,sat = distribute_loop', + all(dashboard._next_boundary(n, 10, [2, 5], fr) + == distribute_loop.next_boundary(n, 10, [2, 5], fr) + for n in drift_dts)) + check('V44 drift-guard skalar = distribute_loop', + all(dashboard._next_boundary(n, 10, 2) + == distribute_loop.next_boundary(n, 10, 2) + for n in drift_dts)) + check('V44 parse_weekdays mirror', + dashboard._parse_weekdays('wed,sat') == [2, 5] + and dashboard._parse_weekdays('sat') == [5]) # halaman melebihi total → dibatasi (tidak error) resp = client.get('/?page=99') diff --git a/test_distribute_loop.py b/test_distribute_loop.py index ed24084..ddcfeea 100644 --- a/test_distribute_loop.py +++ b/test_distribute_loop.py @@ -1,9 +1,10 @@ -"""Oracle jadwal distribusi mingguan (V37): next_boundary + parser. +"""Oracle jadwal distribusi (V37/V44): next_boundary + parser. Murni logika kalender — ⊥ jaringan/DB. Verifikasi: first_run one-off diambil bila lebih dekat dari mingguan; batas dihitung ulang tiap iterasi loop (distribute_loop.main loop tak pernah keluar); first_run yang sudah lewat -dibuang → hanya jadwal mingguan. +dibuang → hanya jadwal mingguan; V44: jadwal multi-hari (CSV `wed,sat`) — event +terdekat di antara hari-hari yang terdaftar, tetap hormati gating first_run. """ from datetime import datetime @@ -36,6 +37,24 @@ def main(): pass check('parse_weekday invalid raise', True) + # parse_weekdays (V44): satu hari atau CSV → list int unik terurut + check('parse_weekdays sat', dl.parse_weekdays('sat') == [5]) + check('parse_weekdays wed,sat', dl.parse_weekdays('wed,sat') == [2, 5]) + check('parse_weekdays kebalikan', dl.parse_weekdays('sat,wed') == [2, 5]) + check('parse_weekdays duplikat', dl.parse_weekdays('wed,sat,wed') == [2, 5]) + check('parse_weekdays angka CSV', dl.parse_weekdays('2,5') == [2, 5]) + try: + dl.parse_weekdays('') + raise AssertionError('GAGAL: parse_weekdays kosong harus raise') + except ValueError: + pass + try: + dl.parse_weekdays('xyz') + raise AssertionError('GAGAL: parse_weekdays tak valid harus raise') + except ValueError: + pass + check('parse_weekdays invalid raise', True) + # parse_first_run: kosong → None, format → date check('parse_first_run kosong', dl.parse_first_run('') is None) check('parse_first_run None', dl.parse_first_run(None) is None) @@ -94,6 +113,42 @@ def main(): t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 2) check('Rabu → Rabu 12 Agt', t == datetime(2026, 8, 12, 10, 0)) + # — V44: jadwal multi-hari Rabu & Sabtu ([2, 5]) — + # Sebelum launch: Rabu/Sabtu sebelum 17 Agt dilewati → one-off Senin 17 Agt + t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, [2, 5], fr) + check('wed,sat sblm launch → one-off 17 Agt', + t == datetime(2026, 8, 17, 10, 0)) + + # Setelah launch (Senin 17 Agt siang) → Rabu 19 Agt (bukan Sabtu 22) + t = dl.next_boundary(datetime(2026, 8, 17, 11, 0), 10, [2, 5], fr) + check('wed,sat stlh launch → Rabu 19 Agt', + t == datetime(2026, 8, 19, 10, 0)) + + # Setelah Rabu 19 Agt siang → Sabtu 22 Agt (jeda terdekat berikutnya) + t = dl.next_boundary(datetime(2026, 8, 19, 11, 0), 10, [2, 5], fr) + check('wed,sat stlh Rabu lewat → Sabtu 22 Agt', + t == datetime(2026, 8, 22, 10, 0)) + + # Tanpa first_run: dari Kamis 6 Agt → Sabtu 8 Agt (lebih dekat dari Rabu 12) + t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, [2, 5]) + check('wed,sat tanpa fr → Sabtu 8 Agt', + t == datetime(2026, 8, 8, 10, 0)) + + # Rabu pagi sebelum jam → Rabu hari itu juga + t = dl.next_boundary(datetime(2026, 8, 12, 9, 0), 10, [2, 5]) + check('wed,sat Rabu jam sblm → hari ini', + t == datetime(2026, 8, 12, 10, 0)) + + # Rabu siang setelah jam → Sabtu 15 Agt + t = dl.next_boundary(datetime(2026, 8, 12, 11, 0), 10, [2, 5]) + check('wed,sat Rabu jam lewat → Sabtu 15 Agt', + t == datetime(2026, 8, 15, 10, 0)) + + # Skalar int tetap kompatibel (normalisasi ke list) + t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 5) + check('skalar int kompatibel → Sabtu 8 Agt', + t == datetime(2026, 8, 8, 10, 0)) + print(f'SEMUA UJI PASS ({CHECKS} checks)')