jadwal distribusi multi-hari Rabu & Sabtu (V44/T49): DISTRIBUTE_WEEKDAY=wed,sat (CSV), next_boundary pilih hari terdekat, banner BERITA SETIAP RABU & SABTU ; image web+dist rebuild + push

This commit is contained in:
proitlab committed 2026-08-07 15:49:26 +07:00
1 parent bdb5923097
commit a6db3be436
10 files changed
+173 -70

No files matched your search

+5 -5
View File
@@ -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
+4 -4
View File
@@ -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).
+4 -2
View File
@@ -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
+5 -5
View File
@@ -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) —————————————————
+38 -20
View File
@@ -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):
+41 -24
View File
@@ -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)
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -28,7 +28,7 @@
<h2 class="news-title">DISTRIBUSI BERIKUTNYA</h2>
<p class="news-date">{{ next_schedule }}</p>
{% if schedule_recur %}
<p class="caption">{{ schedule_recur }}</p>
<p class="caption">{{ schedule_recur|safe }}</p>
{% endif %}
</section>
+16 -5
View File
@@ -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')
+57 -2
View File
@@ -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)')