AP127 Flight-Training Ecosystem

Interactive reference — every website, worker, data feed, deployment & the Telegram watchdog. Reconstructed from source.
GitHub org · AP127CMD CF acct ae38e04e… 5 sites · 3 workers · 2 data feeds anusorn-tanmetha.workers.dev CF verified · relay ✅ · ops feed = 3rd-party

00Bird's-eye overview

Two independent data domains — Operations (what is flying, when, with whom) and Progress (how far each student has advanced) — are merged only at the presentation layer. Every site is a static front-end; all "backend" work is GitHub Actions cron jobs that commit data files into repos, plus three tiny Cloudflare Workers.

AP124 AP126 AP127 (focus) AP128 AP129
flowchart TD classDef src fill:#141a24,stroke:#3a4658,color:#e6edf3; classDef site fill:#1a1320,stroke:#d850c8,color:#f4d9ef; classDef wkr fill:#15110a,stroke:#f4a050,color:#ffe3c2; classDef data fill:#0e1219,stroke:#2a3340,color:#cdd6e0; GS[("Google Sheets
per-batch CSV")]:::src PORTAL[("Flight Ops Portal
Google Apps Script")]:::src RELAY[/"Apps Script RELAY
CORS bridge"/]:::src GS --> RELAY --> UC["update-cache.js
DB001 · Action"]:::data UC --> CACHE["cache.json
4 batches"]:::data CACHE --> KV[("CF KV
ap127_slice")]:::data KV --> API["ap127-data-api
worker"]:::wkr PORTAL -->|Playwright scrape| FS["fetch_schedule.py
CMD_CTR · Action"]:::data FS --> FD["flight-data.js"]:::data FD --> CC["CMD_CTR
ops dashboard"]:::site CACHE --> DB001["DB001
admin site"]:::site UC --> STU["student.html"]:::data STU --> DBSHARE["DB_Share
student site"]:::site API --> DBSHARE FD --> V2["CMDV2
unified SPA"]:::site API --> V2 CACHE --> V2 FD --> WD["ap127-watchdog
Telegram"]:::wkr WD -->|notify| TG(["Telegram SP"]):::src WD --> V2 DISP["ap127-dispatcher
cron */5"]:::wkr -.dispatch.-> FS DISP -.dispatch.-> UC CC -.trigger.-> V2 PORTALL["Portal
launcher"]:::site -.links.-> CC PORTALL -.links.-> DB001 PORTALL -.links.-> DBSHARE PORTALL -.links.-> V2
Data is joined by student name, never by array position — upstream reordering can never shift everyone's labels.

01The five websites

Plus one experimental optimizer (flight-scheduler) that is local-only and not part of the deployed set.

flight-scheduler — experimental optimizer (not deployed)

Google OR-Tools CP-SAT · FastAPI + SQLAlchemy 2 · PostgreSQL 16 · React/TS/Vite · Docker Compose. Treats scheduling as constraint optimization (airport hours, fleet, maintenance, duty windows, prerequisites, shared-FI, leaves). Local ~/flight-scheduler, no git remote. docker compose up --build seeds 24 students / 8 instructors / 10 aircraft / 101 lessons.

02Cloudflare Workers — 3 services

All free-tier, all in account ae38e04e56d0ae52d3ec47ad29977587.

⏱ ap127-dispatcher

ap127-dispatcher.anusorn-tanmetha.workers.dev · cron */5
JobPOSTs workflow_dispatch to DB001 update-cache.yml and CMD_CTR fetch_schedule.yml. Does NOT dispatch CMDV2 — CMD_CTR chains that itself.
AuthGITHUB_PAT secret
WhyGitHub cron is ~hourly best-effort; this gives true 5-min cadence. In-repo cron '0 * * * *' is only a fallback.
RepoDB001/dispatcher/

🔌 ap127-data-api live

ap127-data-api.anusorn-tanmetha.workers.dev
JobReads KV ap127_slice, returns JSON, CORS-locked to the student site.
BindKV KVAP127_STUDENT_DATA (c5c88c81…)
VarALLOWED_ORIGIN = ap127-dashboardr1.pages.dev

📡 ap127-watchdog live

ap127-watchdog.anusorn-tanmetha.workers.dev · cron */5
JobDiffs AP-127 flights, DMs the affected SP via Telegram Bot API; HTTP API backs the CMDV2 Watchdog tab.
BindKV ap127-watchdog-AP127_WD (b42f3202…ccd5)
SecretsTELEGRAM_BOT_TOKEN · TELEGRAM_CHAT_ID · WATCHDOG_API_KEY

ap127-data-api worker source

export default {
  async fetch(request, env) {
    const allowedOrigin = env.ALLOWED_ORIGIN || '*';
    if (request.method === 'OPTIONS') return new Response(null, { headers: {
      'Access-Control-Allow-Origin': allowedOrigin,
      'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '86400' }});
    if (request.method !== 'GET') return new Response('Method not allowed', { status: 405 });
    const data = await env.KV.get('ap127_slice', 'json');
    if (!data) return new Response(JSON.stringify({ error: 'No data' }), { status: 503,
      headers: { 'Content-Type': 'application/json' }});
    return new Response(JSON.stringify(data), { headers: {
      'Content-Type': 'application/json', 'Cache-Control': 'no-store',
      'Access-Control-Allow-Origin': allowedOrigin }});
  },
};

03Data sources & feeds

Operations feed → flight-data.js

A Google Apps Script web-app renders the academy Flight Operations Portal. The schedule lives in a JS object flightCache inside a sandboxed cross-origin iframe — so a headless Playwright browser is needed, not a plain GET.

URLhttps://script.google.com/macros/s/AKfycbzsOcPHLUpD5U8Qyq-x78edIOMUr28NJAp0KTvJvYCW6IQ_yG-HB97aRue8aFoxGQ5lJg/exec
1fetch_schedule.py — launch headless Chromium, goto /exec (90s budget; GAS cold-starts), wait for the iframe src, enumerate page.frames, frame.evaluate() out flightCache. Up to 3 attempts, 20/40s backoff.
2validate_raw_cache() — hard-fail on schema break (data not saved). Then normalize_entry() per row: -→null, derive isSimulator/isStandby/durationMin.
3Merge into data/flight_schedule.json — fresh dates overwrite, dates outside the rolling ~10-day window preserved. Rolling backup written first.
4generate_flight_data.jsflight-data.js (window.FLIGHT_DATA), strips actuals from non-Completed flights, appends ?v=<unix-ts> cache token. Recovery: rebuild_history.py replays all git commits.

Progress feed → cache.json

Google Sheets published as per-batch CSV, read through a second Apps Script relay (CORS bridge = the RELAY_URL secret).

1update-cache.js fetches RELAY_URL + "?batch=" + batch for each batch.
2parseCSV() — 3-rows-per-student format; silently drops any lesson/entry matching /^AUPRT/i (never counted anywhere).
3runScheduler() — projects each student's planned[], next_lesson, finish date, remaining, pct (workday/holiday-aware).
4Writes cache.json — keys ap124 ap126 ap127 ap129 monthly cur124 cur126 cur127 cap _updated. push-to-kv.js PUTs only {ap127,cur127,_updated} to KV ap127_slice.
✅ Relay Apps Script (supplied). A trivial CORS bridge — maps ?batch= to a published Google Sheet CSV (gid 416854743) and proxies it as text/plain. Deploy as Web app · Execute as Me · Anyone → that /exec URL is the RELAY_URL secret.
const CSV_URLS = {
  AP124: "docs.google.com/spreadsheets/d/e/2PACX-1vRQK_to…/pub?gid=416854743&output=csv",
  AP126: "docs.google.com/spreadsheets/d/e/2PACX-1vQB8PiZ…/pub?gid=416854743&output=csv",
  AP127: "docs.google.com/spreadsheets/d/e/2PACX-1vQNxzCi…/pub?gid=416854743&output=csv",
};
function doGet(e){
  const url = CSV_URLS[(e.parameter.batch||"").toUpperCase()];
  if(!url) return json({error:"Unknown batch"});
  return ContentService.createTextOutput(UrlFetchApp.fetch(url).getContentText("UTF-8"))
           .setMimeType(ContentService.MimeType.TEXT);
}
✅ Ops Portal = third-party. The AKfycbzs…/exec Apps Script is an academy system the owner cannot access — the operations feed is an external black box scraped by Playwright. This is the system's single most fragile point: if the academy changes the portal, fetch_schedule.py breaks (→ fetch-failure Issue).

runScheduler — greedy capacity simulator (not a fetch)

After parsing the CSVs, update-cache.js projects every student's future lessons forward to compute planned[] · finish · monthly.

BATCHES = AP124 · AP126 · AP127 (only real feeds) cap 25 slots/operating-day horizon 800 workdays priority AP124→126→127→129 rest-gap 2d if last ≥120min else 1d rank by remaining ÷ workdays-left
AP129 is synthetic — not a feed. CFG.n129:13 placeholder students (AP129-01 "Student 01", 0 done) are generated in-code from ap129start 2026-06-01, using the AP127 curriculum as a stand-in — a capacity projection of a batch that hasn't started. No missing CSV.
Identity arrays hard-coded in update-cache.js: AP127_NICKS / AP127_FI / AP127_SE (28 index-matched entries) + AP127_FI_FULL map, assigned to AP127 students by position. CSV student rows kept only if catc_id starts with 681; /^AUPRT/i lessons dropped.

04Auto-fetching mechanism

End to end, every 5 minutes, driven by the dispatcher worker.

sequenceDiagram participant D as ap127-dispatcher (*/5) participant DB as DB001 Action participant CC as CMD_CTR Action participant V2 as CMDV2 Action participant KV as CF KV participant SH as DB_Share repo D->>DB: workflow_dispatch update-cache.yml D->>CC: workflow_dispatch fetch_schedule.yml DB->>DB: update-cache.js → cache.json DB->>DB: build-student.js → student.html DB->>KV: push-to-kv.js (ap127_slice) DB->>SH: sync-dashboardr1.js (push index.html) CC->>CC: Playwright scrape → flight-data.js CC->>V2: workflow_dispatch refresh-data.yml (on success) V2->>V2: refresh_snapshots.mjs → 3 data files (commit if changed) Note over DB,V2: Pages auto-rebuilds on push to main

Reliability features

git pull --rebase before push concurrency groups (no overlap) Playwright Chromium cached (~2 min/run saved) --with-deps always (cold-cache safe) failure → auto GitHub Issue (fetch-failure / refresh-failure) commit only on real change
CMDV2 refresh-data.yml also self-runs on cron 20 * * * * (offset 20 min after CC's top-of-hour) as a fallback.

05Telegram notification system — Watchdog

The Telegram system is entirely the ap127-watchdog Cloudflare Worker (repo CMDV2/watchdog/). No Telegram code exists anywhere else in the repos. Every 5 min it fetches AP-127 flights, diffs vs the previous snapshot, and sends one Telegram message per change to the affected student.

flowchart LR classDef w fill:#15110a,stroke:#f4a050,color:#ffe3c2; classDef k fill:#0e1219,stroke:#2a3340,color:#cdd6e0; RAW[("raw.githubusercontent
CMD_CTR/flight-data.js")]:::k RAW --> F["fetch + filter
batch=AP-127"]:::w PREV[("KV watchdog:snapshot")]:::k --> DIFF F --> DIFF["diffSnapshots()"]:::w DIFF --> EV{"event type"}:::w EV -->|ADDED| TG EV -->|REMOVED| TG EV -->|STATUS| TG EV -->|CHANGED| TG TG["formatMessage +
sendTelegram()"]:::w --> BOT(["Telegram Bot API"]):::k DIFF --> SNAP[("write snapshot
if changed")]:::k TG --> LOG[("KV watchdog:log:YYYY-MM")]:::k

Tracked fields

Any change fires an event:
datestartendstatusinstructortaillesson
Not tracked: actuals (tkoff, ldgTime, airborne, to, ldg, inst), cond, isSim, isStandby, durMin, duration.

Event → message

ADDED✈️ New flight scheduled
REMOVED❌ Flight cancelled
STATUS🔄 Status update Pending→Completed
CHANGED⚠️ Flight updated (time/date/aircraft/FI/lesson)
SP name → @username via roster config; 1s pause between sends (rate-limit).

HTTP API (Watchdog tab)

GET/status · /config · /log?month=
POST/config · /test (need X-API-Key)
CORS allow-list: ap127-ngt2.pages.dev

KV keys (AP127_WD)

watchdog:snapshot — diff base
watchdog:config — roster + prefs
watchdog:status — heartbeat
watchdog:log:YYYY-MM[-A/B] — sharded @20MB

Message format (src/telegram.js)

✈️ New flight scheduled
SP: @username
📅 10 Jun 2026  08:00–09:30
📖 Lesson: CDGL 04
🛩 HS-NGT  |  FI: ITTIPOL P.

06Deployment

Sites — confirmed live (all 4 main = CF Pages Git build on push to main)

Pages projectRepoNotes
ap127-cmd-ctrCMD_CTRfetch_schedule.yml is a data job, not a deploy
ap127-db001DB001deploy-pages job in update-cache.yml is legacy/dead — CF Pages wins
ap127-dashboardr1DB_Share (private)content written by sync-dashboardr1.js; redeploys ~hourly
ap127-ngt2CMDV2the live unified SPA + watchdog CORS origin
GitHub PagesPortalstatic.yml → ap127cmd.github.io/Portal/
Drift resolved: every main site shows Git Provider: Yes in Cloudflare — CF Pages is authoritative; the in-repo GitHub-Pages steps are dead code. Two extra projects ap127-cmdv2 / ap127-cmdv2-ngt-imp1 appear to be old/staging CMDV2 deploys.

Workers

WorkerHow deployed
ap127-dispatcherdeploy-dispatcher.yml on push to dispatcher/**: wrangler deploy + wrangler secret put GITHUB_PAT. Token CF_WORKERS_TOKEN = Workers Scripts:Edit + Account:Read.
ap127-data-apiManual wrangler deploy (no in-repo workflow), 2026-05-21. Bind KV AP127_STUDENT_DATA (c5c88c81…), set ALLOWED_ORIGIN.
ap127-watchdogwrangler deploy from CMDV2/watchdog/; KV bound by id; 3 secrets via wrangler secret put.

Confirmed Cloudflare ids & URLs

ResourceValue
Accountae38e04e56d0ae52d3ec47ad29977587 · anusorn.tanmetha@gmail.com
workers.dev subdomainanusorn-tanmetha.workers.dev
KV AP127_STUDENT_DATAc5c88c813d8d4f668f6081506ad98bcd
KV ap127-watchdog-AP127_WDb42f3202c5364f91aef3837132d6ccd5
Worker ap127-data-apiap127-data-api.anusorn-tanmetha.workers.dev (= CF_WORKER_URL)
Worker ap127-watchdogap127-watchdog.anusorn-tanmetha.workers.dev
Worker ap127-dispatcherap127-dispatcher.anusorn-tanmetha.workers.dev (cron-only)

07Secrets & environment inventory

Values are never stored — this is where each secret lives and what it does.

GitHub Actions — DB001

SecretPurpose
RELAY_URLApps Script relay (CSV CORS bridge); injected into index.html
ADMIN_PASSWORD_HASHSHA-256 of admin password; injected into index.html
CF_ACCOUNT_IDCloudflare account id
CF_KV_NAMESPACE_IDKV namespace id for AP127_STUDENT_DATA
CF_API_TOKENCF token w/ KV write (push-to-kv.js)
CF_WORKER_URLap127-data-api URL; injected into student.html + DB_Share
GH_PAT_DASHBOARDR1fine-grained PAT, Contents R/W on DB_Share only
CF_WORKERS_TOKENCF token, Workers Scripts:Edit + Account:Read (dispatcher deploy)
GH_PAT_DISPATCHERPAT uploaded to dispatcher worker as GITHUB_PAT

GitHub Actions — CMD_CTR

SecretPurpose
GH_PAT_WORKFLOWPAT to workflow_dispatch CMDV2 refresh-data.yml after a fetch

Cloudflare Worker secrets/vars

WorkerSecrets / vars
ap127-dispatcherGITHUB_PAT
ap127-data-apiALLOWED_ORIGIN (var) · KV binding
ap127-watchdogTELEGRAM_BOT_TOKEN · TELEGRAM_CHAT_ID · WATCHDOG_API_KEY · KV

08Reproduce from scratch

Order matters — later steps depend on earlier IDs/URLs.

1Google data layer — progress Sheets published as CSV; deploy the relay Apps Script (→ RELAY_URL). The Flight Ops Portal (→ /exec in fetch_schedule.py) is a third-party academy system — point the scraper at the real portal or your own equivalent.
2GitHub org + repos — create AP127CMD/{CMD_CTR,DB001,DB_Share,CMDV2,Portal}; push code.
3Cloudflare KV — create AP127_STUDENT_DATA and AP127_WD; note ids.
4Workers — deploy ap127-data-api, ap127-watchdog, ap127-dispatcher; bind KV + set secrets.
5Telegram — @BotFather bot → TELEGRAM_BOT_TOKEN; chat id → TELEGRAM_CHAT_ID; generate WATCHDOG_API_KEY; map SP names → @usernames in watchdog:config.
6GitHub secrets — add every secret from the Secrets tab to the right repo.
7Cloudflare Pages — create 4 Pages projects connected to their repos, building on push to main.
8Portal — enable GitHub Pages on AP127CMD/Portal (static.yml deploys).
9Kick the pipeline — run update-cache.yml + fetch_schedule.yml once; verify cache.json, flight-data.js, KV ap127_slice, and a Telegram /test message.
Ops-feed caveat: step 1's Flight Ops Portal is a third-party academy system — a clone can't recreate it; you must get portal access or substitute your own schedule source exposing an equivalent flightCache. The relay (progress feed) and everything else is fully reproducible from these docs.
Minor open items: deprecate the two ap127-cmdv2* duplicate Pages projects? · confirm any WAF rate-limit rule on the data worker · confirm DB_Share index.html has no manual drift.