Skip to content

Architecture

chin52696411 edited this page Jul 25, 2026 · 2 revisions

Architecture

Layering

Three layers that never reach across the wrong way:

  • poketrack.core — data layer: models, database, parser, scheduler, service. Knows nothing about any UI (no Tk, no Flask imports).
  • poketrack.gui — legacy desktop presentation (CustomTkinter).
  • poketrack.web — web presentation (Flask).
  • desktop/ — the native Tauri desktop shell (Rust). Not a fourth UI layer in the Python sense — it spawns poketrack.web's Flask server as a sidecar process and opens a native window pointed at it, so it reuses poketrack.web entirely rather than reimplementing presentation. See Desktop Shell.

Both Python front-ends talk only to PokeTrackService (core/service.py) — never to the parser or database directly. That's the single seam that keeps fetching/parsing fully separated from presentation.

            ┌──────────────────────────────┐
            │     PokeTrackService          │  ← the only thing both UIs touch
            │  (core/service.py)            │
            └───┬───────────┬───────────┬───┘
   parser.py    │  database │  scheduler│
 (LeekDuck/Blog)│  (SQLite) │ (APSched) │
                ▼           ▼           ▼
        ┌───────────────┐   ┌──────────────────┐
        │ gui/app.py    │   │ web/server.py    │
        │ CustomTkinter │   │ Flask            │
        └───────────────┘   └──────────────────┘
                 ▲                   ▲
                 └──── theme.py ─────┘   (one Midnight Blue palette)

Module map

poketrack/
├── app_context.py   # builds the shared service; RESOURCE_ROOT vs ROOT (frozen-aware)
├── config.py         # config.json manager (deep-merge over defaults, dotted-path get/set)
├── i18n.py            # Translator — dotted-key lookup, current→English→key fallback
├── core/
│   ├── models.py     # Event dataclass — parsing, status/countdown logic, (de)serialisation
│   ├── regions.py    # region constants + keyword classifier (data/regions_map.json)
│   ├── database.py   # SQLite persistence — thread-safe (connection-per-op), WAL, migrations
│   ├── http.py        # shared requests.Session with retry/backoff + User-Agent
│   ├── parser.py      # LeekDuckSource (JSON) + PokemonGoBlogSource (HTML), sync + async fetch
│   ├── native.py       # loader for the optional Rust fast path (guarded import)
│   ├── notify.py        # optional desktop notifications (plyer, guarded)
│   ├── webhook.py        # outgoing webhooks — payload shaping + HMAC signing
│   ├── telegram.py        # Telegram Bot API sendMessage wrapper
│   ├── calendar.py         # hand-rolled iCalendar (.ics) builder
│   ├── updates.py           # best-effort GitHub-releases update check (fail-silent)
│   ├── asyncrunner.py       # background asyncio event loop for the desktop GUI
│   ├── scheduler.py          # APScheduler wrapper (interval trigger, safe job wrapper)
│   └── service.py             # PokeTrackService — the shared controller (see below)
├── gui/
│   ├── theme.py        # MIDNIGHT_BLUE (dark) + PAPER_LIGHT palettes — single source of truth
│   ├── images.py         # async thumbnail loader (off-thread, on-disk cache)
│   ├── tray.py             # optional system-tray icon (pystray, guarded)
│   └── app.py                # legacy CustomTkinter application
└── web/
    ├── server.py        # Flask app + JSON API (see Web API)
    ├── templates/         # Jinja2 — base.html injects the shared palette into Tailwind
    └── static/              # css/, icons/, dist/ (compiled TS bundle)

desktop/                  # native Tauri desktop shell — spawns web/server.py as a
                           # sidecar and opens a native window at it (see Desktop Shell)

PokeTrackService — the shared controller

Everything both UIs need funnels through one object:

  • Fetching: refresh_now() (sync) / refresh_now_async() (async, used by the desktop GUI so network I/O never blocks the Tk main loop). Both call _apply_fetch(), which upserts into SQLite, detects genuinely new events (vs. a first-ever populate, so it doesn't announce 60 events at once), and optionally fires notifications/webhook/Telegram.
  • Queries: get_events(...) — applies the region filter by default (an event shows if it's Global or its region is selected), plus optional search/type/favorites narrowing.
  • Display helpers: countdown(), description(), format_time() — all localized through the shared Translator.
  • Settings: typed setters (set_webhook, set_telegram, set_time_format, …) that write through Config and, where relevant, immediately re-apply to running components (e.g. set_interval reschedules the live APScheduler job).
  • Lifecycle: start()/stop() — starts the background scheduler and (optionally) an immediate first fetch on a daemon thread.

Threading model

  • SQLite: one connection per operation (Database._connect()), never shared across threads — safe for the Tk main thread and the APScheduler background thread to both touch the DB.
  • Desktop GUI: all Tk objects are created/touched only on the main thread. Background work (fetches, image downloads) posts results onto a queue.Queue that the main loop drains (_ui_queue) — this is the only handoff point between worker threads and Tk.
  • Async: the desktop GUI runs a persistent background asyncio loop (core/asyncrunner.py, RUNNER singleton) so await-based network I/O (httpx) doesn't need a fresh loop per call; sync Database work inside an async path is offloaded via asyncio.to_thread.

Data model

Event (core/models.py) is the single source-agnostic representation. Parsers (LeekDuckSource, PokemonGoBlogSource) build Event objects; Database (de)serializes them to/from SQLite rows (highlights — bosses, promo codes, spawn/research flags — are stored as a JSON blob in one extra column); both UIs render the same object. Datetimes are always normalized to timezone-naive local wall-clock time at parse time (_parse_dt) — this was a real bug fix (mixed naive/aware inputs previously crashed comparisons) and every downstream comparison depends on that invariant holding.

i18n

Translator (i18n.py) loads languages.json once and resolves dotted keys (events.view_details) with current language → English → the key itself fallback, so a missing translation degrades visibly instead of crashing. service.t(key, **kwargs) is the pass-through both UIs call — never a hard-coded string in a template or widget.

Polyglot design

PokéTrack intentionally uses more than one language, each doing what it does best — but Python remains the orchestrator, and both additions are optional with automatic fallback, verified by parity tests.

Rust native fast path (poketrack-native/)

A PyO3 extension (parse_feed, classify_region) that parses the ScrapedDuck feed and infers regions in native code, built as an abi3 wheel (cp39-abi3 — one wheel works on any CPython ≥ 3.9, no per-interpreter rebuild). poketrack/core/native.py imports it if present; parser.py's _parse_text() uses it when available and falls back to the pure-Python path on any import/runtime failure — the app and the full test suite behave identically either way. Measured on the live feed, the native JSON→structured-data step is ~3–5× faster; the end-to-end gain is smaller because building Python Event objects (unavoidable Python work) dominates — see poketrack-native/benchmark.py for the honest numbers.

TypeScript web front-end (web-frontend/)

The web UI's interactive layer is TypeScript, bundled by Vite into a committed poketrack/web/static/dist/app.js — running the app needs no Node, only rebuilding does. It progressively enhances the Flask server-rendered page (works with JS disabled): live-ticking countdowns, instant client-side search, no-reload favorite toggling (POST /api/favorite), and the async refresh/poller.

See Contributing for how to build/test each piece, and Deployment for how CI and releases assemble everything.

Clone this wiki locally