ECTLogger Development Guide
Project Structure
ectlogger/
βββ backend/
β βββ app/
β β βββ main.py # FastAPI app, CORS, rate limiting, WebSocket, routers
β β βββ config.py # Pydantic Settings (reads backend/.env)
β β βββ database.py # Async SQLAlchemy session factory
β β βββ models.py # All SQLAlchemy ORM models
β β βββ schemas.py # All Pydantic request/response schemas
β β βββ auth.py # JWT creation and verification
β β βββ dependencies.py # FastAPI dependency functions (get_current_user, etc.)
β β βββ permissions.py # check_net_permission, check_template_permission
β β βββ security.py # Input sanitization, rate-limit helpers
β β βββ session_config.py # Session rolling-renewal logic
β β βββ email_service.py # EmailService facade β assembles email/*.py
β β βββ email/ # base, auth, net_lifecycle, reminders, net_logs, digest
β β βββ ncs_reminder_service.py # Background NCS reminder scheduler
β β βββ whats_new_service.py # Background "What's New" digest scheduler
β β βββ logger.py # Structured application logger
β β βββ utils.py # Shared utility functions
β β βββ traffic/ # Assisted Traffic Handling domain logic (not a router β
β β β # reused by the traffic routers, ICS-309 export, and stats)
β β β βββ definitions.py # Startup upsert of form_definitions from definitions/*.json
β β β βββ definitions/ # manifest.json, radiogram.json, ics213.json, arl_messages.json
β β β βββ nts_text.py # normalize_nts_text, count_nts_check (NTS_SUBSTITUTIONS)
β β β βββ radiogram.py, ics213.py, formatters.py # per-form-type formatting/parsing
β β β βββ arl.py # ARL numbered-message catalog loader
β β β βββ promote.py # Form.field_values -> promoted columns (subject, precedence, ...)
β β β βββ log.py # append_entry (chain-of-custody), derive_disposition,
β β β β # compute_net_traffic_counts (shared by the summary endpoint
β β β β # and the net-close email)
β β β βββ visibility.py # form_visibility_clause β the D3 visibility WHERE clause
β β β βββ ics309.py # Metadata-only ICS-309 row builder (never the message body)
β β βββ routers/ # See "Backend router-split (facade) pattern" below
β β βββ auth.py, users.py, check_ins.py, frequencies.py, chat.py,
β β β settings.py, ncs_rotation.py, ncs_schedule.py, security.py,
β β β geocode.py, contacts.py, feedback.py # single-file routers
β β βββ nets.py # facade β includes nets_{core,polls,export,roles}
β β βββ templates.py # facade β includes templates_{core,merge,subscriptions,topics}
β β βββ traffic.py # facade β includes traffic_{definitions,forms,export}
β β β # (traffic_log.py β chain-of-custody append endpoint,
β β β # inbox, import, reminders β is a later phase, not registered yet)
β β βββ statistics.py # facade β includes statistics_{global,net,user,geo}
β βββ migrations/ # Sequentially numbered Python migration scripts (sqlite3 direct)
β β # β see "Migration content guidelines" in migrations/README.md
β βββ requirements.txt
βββ frontend/
β βββ src/
β β βββ components/ # See "Frontend component-split pattern" below
β β β βββ UserAvatar.tsx, Navbar.tsx, ... # standalone shared components
β β β βββ admin/ # Admin.tsx's extracted tab components
β β β βββ create-net/ # CreateNet.tsx's extracted tab components
β β β βββ create-schedule/ # CreateSchedule.tsx's extracted tab components
β β β βββ dashboard/ # Dashboard.tsx's extracted pieces (NetCard, ...)
β β β βββ forms/ # Shared form panels (used by CreateNet + CreateSchedule)
β β β βββ ncs-staff/ # NCSStaffModal.tsx's extracted tab components
β β β βββ netview/ # NetView.tsx's extracted dialogs/panels/tables, incl. TrafficPanel.tsx
β β β βββ profile/ # Profile.tsx's extracted tab components
β β β βββ scheduler/ # Scheduler.tsx's extracted pieces (ScheduleCard, ...)
β β β βββ traffic/ # pages/Traffic.tsx's extracted components (FormRenderer,
β β β # RadiogramAssist, TrafficTable, TrafficDetail, ...)
β β βββ hooks/ # useLocalStorage, useDialog, useApiData, useSortableTable,
β β β # useFavorites, useNetData, useNetWebSocket, useUserStats
β β βββ contexts/ # React contexts (AuthContext, ThemeContext, LocationContext)
β β βββ pages/ # Full-page components (one file per route) β the "smart"
β β β # controller that owns page-level state and data fetching
β β βββ services/
β β β βββ api.ts # Axios client, all API call functions
β β βββ utils/ # dateUtils, pdfExport, userDisplay, apiErrors, etc.
β β βββ App.tsx # Router, theme, global layout
β β βββ changelog.json # Single source of truth for What's New content
β β βββ main.tsx # React entry point
β βββ public/
β βββ maintenance.html # Static maintenance page (no JS framework)
βββ docs/ # All documentation
β βββ DESIGN.md # UI patterns and conventions β read before adding UI
β βββ CHANGELOG.md # Human-readable changelog
β βββ ROADMAP.md # Canonical feature roadmap
β βββ PRODUCTION-DEPLOYMENT.md
β βββ QUICKSTART.md
β βββ USER-GUIDE.md
βββ backend/.env # Local config (gitignored); copy from .env.example
βββ run.sh # Consolidated operational script (start/update/maintenance)
βββ start.sh # Deprecated β use run.sh
βββ update.sh # Still invoked by run.sh --update
βββ install.sh # One-time installation
βββ configure.sh # One-time Caddy/env configuration
βββ install-service.sh # One-time systemd service installation
Backend router-split (facade) pattern
Three routers outgrew a single file (nets.py at 2,207 lines, templates.py at
1,349, statistics.py at 1,022) and were split during Milestone 0.4. The split
files use a facade: the original filename becomes a thin file that only
imports and assembles sub-routers, so every route keeps its original URL and
import path (from app.routers.nets import router still works) β nothing
outside routers/ needs to know the file was split.
# routers/nets.py (facade β the whole file, give or take)
from fastapi import APIRouter
from app.routers.nets_core import router as nets_core_router
from app.routers.nets_polls import router as nets_polls_router
from app.routers.nets_export import router as nets_export_router
from app.routers.nets_roles import router as nets_roles_router
router = APIRouter(prefix="/nets", tags=["nets"])
router.include_router(nets_core_router)
router.include_router(nets_polls_router)
router.include_router(nets_export_router)
router.include_router(nets_roles_router)
Adding a new endpoint to a split router: add it to the sub-file whose
theme matches (e.g. a new net-export format goes in nets_export.py, not
nets.py). The facade needs no change unless youβre adding a whole new
sub-router file.
The trap that bit us once already: anything that used to live in the
monolithic file (module-level tables, helper functions) and got imported
cross-file via from app.routers.nets import X breaks silently the moment
nets.py becomes a facade β X isnβt defined there anymore, and Python
raises ImportError only when that code path actually runs, not at import
time or in a route-table diff. This is exactly what happened to
net_frequencies (defined in app.models, not app.routers.nets) after the
2026-07-04 split β see the βPost-split verification checklistβ below, which
exists specifically to catch this.
Frontend component-split pattern
Large pages (NetView.tsx was 5,410 lines, Admin.tsx 3,215) were split
during Milestone 0.4 into a pages/<Page>.tsx (kept as the βsmartβ page
controller β owns page-level state, data fetching, and permission logic) plus
components/<page-kebab-case>/<Piece>.tsx (the extracted, mostly-presentational
pieces) and hooks/use<Thing>.ts (extracted data-fetching effects).
Deciding what to extract, and how self-contained to make it:
- Duplicated JSX blocks (e.g. NetViewβs mobile/desktop/detached check-in tables) are the highest-value, lowest-risk extraction β unify them into one parameterized component so future edits canβt silently drift between copies.
- A tab/section whose state and handlers nothing else touches (e.g.
Profile.tsxβs Activity tab, NCSStaffModalβs swap dialog) should become a
fully self-contained component: it calls its own hooks (
useAuth(),useNavigate(), a data-fetching hook) and takes few or even zero props. Verify this by grepping the parent for every piece of that tabβs state β if nothing outside the tabβs own render function reads it, itβs safe to localize. - A tab/section whose handlers ripple into other tabsβ state (e.g. NCSStaffModalβs roster tab β removing a staffer also updates the rotation list and schedule entries) should stay a thin presentational component: keep the state and handlers in the parent page, pass them down as props. Forcing self-containment here would mean duplicating the cross-cutting logic or inventing a shared store β not worth it for a one-off page.
- Purely visual, per-render UI state with no cross-component reader (e.g. a drag-and-drop hover highlight) can be localized into a child component even when the mutation it triggers stays a parent-owned prop β the child just computes what to pass to that prop.
- Donβt force a shared sub-component onto pieces that only look similar. NCSStaffModalβs roster tab has three list variants (schedule staff, net rotation duty, plain net-role list) that share a presentational shape but differ enough in business logic that one forced shared component would obscure more than it saves β it stayed as one file with three branches.
Verifying an extraction preserves behavior exactly: read the target block
fully first, then extract with content copied verbatim (only type-annotation
changes allowed). After wiring up the new import, diff old vs. new with
whitespace/indentation normalized and confirm zero unexplained differences
before committing. tsc --noEmit must stay clean throughout.
Where an extracted piece lives β the point of splitting is reuse, not just smaller files, so give each piece a home based on its reuse scope:
- App-wide β
hooks/and thecomponents/root:useLocalStorage,useDialog,useApiData,useSortableTable,UserAvatar. - Shared between specific pages β also the
components/root, named for the function rather than the page:useFavorites(Dashboard + Scheduler),components/forms/(the panels CreateNet and CreateSchedule would otherwise near-duplicate, which is exactly how those two pages used to drift apart). - Page-local β a page subfolder (
components/netview/,components/admin/). Local until a second consumer appears; promoting to thecomponents/root later is a rename, not a rewrite.
Confirm a reuse fit at extraction time rather than assuming one β donβt force a premature abstraction (KISS).
On file size: the working target is roughly 800 lines per page or router,
but that number is a proxy for βa smaller model can edit this without
collateral damage,β not a goal in itself. NetView.tsx stopped at ~2,289 lines
and NCSStaffModal.tsx at ~1,008 by deliberate decision: every duplicated or
real-time-risky surface had been extracted, and what remained was
page-controller glue (lifecycle handlers, each tied to a different already
extracted dialog) with a wide, heterogeneous dependency set and no clean seam.
Forcing those under 800 would have traded a legible line count for an
illegible boundary. Judge each file on whether its risky and duplicated
surfaces are extracted, not on the raw number.
Post-split verification checklist
Whichever kind of split youβre doing, after moving code out of a file:
- Route-table / component-render diff confirms the shape survived (same endpoints registered, same JSX renders) β necessary but not sufficient.
- Grep the whole repo for cross-file imports of the old file, not just
the thing you moved:
grep -rn "from app.routers.nets import\|from app\.routers\.templates import" backend/ grep -rn "from '../pages/NetView'\|from '../pages/Profile'" frontend/src/Anything that imports a name from the split file that isnβt actually defined in the new facade/thinned page will raise
ImportError(backend) or atscerror (frontend) β but only backendImportErrors are lazy (deferred until the function actually runs), which is why they slip past route-table diffing and can ship silently.tsc --noEmitcatches the frontend equivalent immediately, so this step matters most on the backend. - Add or confirm test coverage for the code path you touched. A route
thatβs registered but never exercised by a test can carry an
ImportErrorin its body indefinitely β route-table diffing and manual click-throughs of the pages youβre actively changing wonβt catch a regression in a feature you didnβt think to re-test. If no test calls the endpoint/service method, add one before considering the split done.
UI Design Reference
Before adding any new UI element, read docs/DESIGN.md. It covers:
- Floating Action Button sizing and positioning rules
- Tab scrollability and swipe-to-switch pattern
- Icon color conventions for toolbar buttons
- Card action buttons (
CardActionButton, management/standard row split, severity ordering, and the requireddisableSpacingonCardActions) - Mobile touch targets and responsive breakpoints
- Net View toolbar row structure
Starting the App
# Full stack β Linux/macOS
./run # Interactive (prompts for update check)
./run --service # Systemd / headless mode
# Full stack β Windows
.\start.ps1
# Backend only
cd backend && source venv/bin/activate
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Frontend only
cd frontend && npm run dev
| URLs: Frontend :3000 | Backend :8000 | API Docs :8000/docs |
Running the Test Suite
Run these checks before every commit (CI enforces the same steps on push to main).
Backend (pytest):
cd backend
pip install -r requirements.txt -r requirements-test.txt # first time only
pytest
Frontend (typecheck + lint + build):
cd frontend
npm run typecheck # tsc --noEmit
npm run lint # ESLint β must exit 0 (warnings are OK, errors are not)
npm run build # vite build β confirms the bundle compiles
CI runs both jobs on every push via .github/workflows/ci.yml.
Adding API Endpoints
- Define Pydantic schemas in
schemas.pywithField()validation - Add route in
routers/*.pywithDepends(get_current_user)andDepends(get_db) - Add client method in
frontend/src/services/api.ts
Pattern for async DB queries (always eager-load to avoid lazy-load errors):
result = await db.execute(
select(Net).options(selectinload(Net.frequencies)).where(Net.id == net_id)
)
net = result.scalar_one_or_none()
Permission check helper:
if not await check_net_permission(db, net, user, required_roles=["ncs", "logger"]):
raise HTTPException(status_code=403, detail="Permission denied")
Repeatable query params (status=a&status=b). A route can accept a list via
status: Optional[List[NetStatus]] = Query(None) β FastAPI parses both a single value and
repeated keys into a list, so existing single-status callers keep working unchanged. The catch
is on the frontend: axiosβs default array serialization (v1.x) turns params: { status: ['a',
'b'] } into status[]=a&status[]=b, which FastAPI does not bind to List[...] (it needs
bare repeated status=a&status=b). Build a URLSearchParams and .append() each value instead
of passing a plain array β axios passes a URLSearchParams instance through untouched. See
netApi.listArchived in services/api.ts (fetches both archived and cancelled nets this
way) and list_nets in routers/nets_core.py.
Guest-readable net data. A netβs view and report pages are intentionally usable with no
account (see docs/USER-GUIDE.md βSharing a Net With Someone Who Doesnβt Have an Accountβ), so
a GET that feeds either page should use Depends(get_current_user_optional) (current_user:
Optional[User]), not Depends(get_current_user), unless the data is genuinely staff-only. When
a response includes free text a user typed (a check-in field, a chat message, an assembled
ICS-309 log entry), pass it through redact_contact_info (app/utils.py) whenever
current_user is None β it strips anything that looks like an email or phone number. A
callsign and licensee name are not redacted; both are already public via the FCC ULS. The
frontendβs axios response interceptor (services/api.ts) only logs a user out on a 401 if a
token was actually being sent β a guest hitting a login-required resource should fail that one
request quietly, not get bounced off a page theyβre allowed to be on.
Database Migrations
Migrations are individual Python scripts in backend/migrations/. They use
sqlite3 directly β no Alembic. The naming convention is NNN_description.py.
# Run on beta
ssh bradb@10.6.26.3 "cd /home/bradb/ectlogger && python3 backend/migrations/034_add_maintenance_banner.py"
# Run on production (activate venv first if the script imports SQLAlchemy)
ssh ectlogger@app.ectlogger.us "cd ~/ectlogger && python3 backend/migrations/034_add_maintenance_banner.py"
Fresh installations do not run migrations β they get the current schema from models.py at startup.
Migration template:
import sqlite3, os
def migrate():
db_path = os.path.join(os.path.dirname(__file__), '..', 'ectlogger.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("PRAGMA table_info(my_table)")
columns = [row[1] for row in cursor.fetchall()]
if 'new_column' not in columns:
cursor.execute("ALTER TABLE my_table ADD COLUMN new_column TEXT")
conn.commit()
print("Migration NNN complete.")
except Exception as e:
conn.rollback(); raise
finally:
conn.close()
if __name__ == "__main__":
migrate()
AppSettings Singleton
Global settings live in a single app_settings row (id=1). Add new settings by:
- Adding columns to
AppSettingsinmodels.py - Adding fields to
AppSettingsResponseandAppSettingsUpdateinschemas.py - Updating
_build_settings_response()and theupdate_settingshandler inrouters/settings.py - Writing a migration
Public settings (readable without auth) get a dedicated endpoint in settings.py;
admin-only settings go through the standard GET /settings / PUT /settings pair.
Settings needed outside a request (in-process cache)
gravatar_enabled is read by utils.get_avatar_url(), which is called from
UserResponse.from_orm() β a synchronous serializer with no database session.
It therefore canβt query app_settings at call time. The pattern used:
utils.pyholds a module-level flag withset_gravatar_enabled()/gravatar_is_enabled().main.pyβs lifespan primes it once at startup (_load_gravatar_setting), tolerating a database that predates the migration so an un-migrated instance still boots.routers/settings.pycalls the setter whenever an admin saves, so the cache never drifts from the stored value.
Use this only for settings genuinely needed outside request scope. It is per-process, so a multi-process deployment updates the saving process immediately and the others on their next restart β acceptable for a cosmetic switch, not acceptable for anything security-relevant. Anything enforcing permissions must be read from the database inside the request.
Avatars and Gravatar
get_avatar_url() returns a Gravatar URL without checking whether the image exists.
This is deliberate and should not be βfixedβ back:
- The old existence probe issued a blocking
urllibHTTP HEAD to gravatar.com per user, ~150-200 ms each, on all twelve endpoints returningUserResponseβ including/users/me, which every authenticated page load hits. 66 users measured at 6.3 s. - Because it was synchronous inside async handlers, it stalled the event loop for every other request while waiting.
- It was also unnecessary. Gravatar is designed for direct client-side embedding, with
the
d=parameter choosing the fallback.d=404makes Gravatar answer 404 when a user has no image, and MUIβs<Avatar>renders its children (the userβs initial) on any load failure β 404, ad-blocker, offline, DNS failure, or CSP violation alike.
So the browser already does this job: in parallel, cached, always current, with no server-side TTL to go stale. Caching the probe server-side would have reintroduced a staleness problem that not probing avoids entirely.
CSP warning: the HTML document is served by Caddy with no CSP today. The CSP set in
main.py applies only to API responses and has no img-src. If a CSP is ever added to
the served frontend, it must include img-src https://www.gravatar.com or every avatar
silently degrades to initials.
Admins can disable Gravatar entirely (Admin β Security β Profile Photos), which stops any Gravatar URL reaching a browser β for isolated or privacy-restricted deployments. Uploaded photos are served locally and are unaffected.
Theming
Named color themes live in frontend/src/theme/themes.ts β a THEMES record
mapping a key (e.g. 'ocean') to a { name, light, dark } definition, each
variant a flat { primary, secondary, background, paper } hex set. App.tsxβs
getDesignTokens(mode, themeKey) reads from this constant instead of
hardcoding colors; everything else (typography, component overrides) is
shared across all themes.
Resolution hierarchy, computed in ThemedApp (App.tsx, mounted inside
AuthProvider specifically so it can read useAuth().user):
user.themeβ the authenticated userβs personal pick (PUT /users/me).- else the system default, fetched once from the public
GET /settings/themeendpoint (app_settings.default_theme, admin-editable via Admin β Branding). - else
DEFAULT_THEME_KEY('ectlogger-blue') as an offline/pre-fetch fallback.
Both user.theme (nullable) and app_settings.default_theme are validated
server-side against VALID_THEME_KEYS in schemas.py β keep that tuple in
sync with THEMESβs keys, plus the always-valid 'custom' key (below).
To add a new curated theme: add one entry to THEMES (pick two accent
hues from a source palette, hand-tune a light/dark background pair) and add
its key to VALID_THEME_KEYS in backend/app/schemas.py. No migration,
endpoint, or component change is needed β ThemeSwatchPicker.tsx (shared by
Profile β Settings and Admin β Branding) renders THEMES generically.
Branding (custom theme, custom logo, default appearance)
Unlike the curated THEMES, per-instance branding is admin-defined data, not
code, so it lives entirely in app_settings (Admin β Branding tab,
AdminBrandingTab.tsx):
default_color_mode('light'/'dark') β only affects a browserβs very first visit (nothemeModekey inlocalStorageyet, tracked via a ref captured before the persist-effect inThemedAppcan write one); once a visitor toggles, their own browserβs choice always wins from then on.custom_theme_jsonβ a single admin-defined theme (not per-user-created), stored as JSON matchingCustomThemeinschemas.py(mirrorsThemeDefinitionβs shape:{name, light: {...}, dark: {...}}). Injected at runtime as the'custom'key whereverTHEMESis rendered (ThemeSwatchPicker) or resolved (getDesignTokensinApp.tsx) β it is never added to the staticTHEMESconstant itself.'custom'is always accepted byVALID_THEME_KEYSeven before an admin has configured one; selecting it with nothing configured just falls back to the default theme.custom_logo_urlβ path to an uploaded logo, served fromLOGO_DIR(app/utils.py, mounted at/api/logoinmain.py) viaPOST/DELETE /settings/logo(admin-only, modeled directly onrouters/users.pyβs avatar upload: Pillow resize/EXIF-transpose for raster formats, a light sanity check for SVG).AppLogo.tsxreads it fromThemeContextand renders an<img>in place of the built-in SVG wherever the component is used, with a small neutral backing circle for thevariant="nav"case so an arbitrary uploaded image stays visible against any active themeβs primary-colored AppBar.
Date & Time Handling
ECTLogger deals with two fundamentally different kinds of time, and they are stored differently on purpose:
1. Concrete net instances β stored in UTC.
Net.scheduled_start_time (and started_at, closed_at, etc.) are absolute instants. They are stored in UTC and rendered in each viewerβs local time:
- Manual creation converts the picker value with
new Date(...).toISOString()(CreateNet.tsx). - Template-created nets convert local β UTC before storage (
routers/templates.py). - The frontend renders with a per-user local/UTC choice (
formatDateTime(..., user.prefer_utc)inDashboard.tsx).
This is what makes multi-timezone nets correct: the net happens at one instant, and a viewer in any zone sees it converted to their own clock.
2. Recurring schedule templates β stored as a local-time recurrence rule + IANA zone.
NetTemplate.schedule_config holds time (e.g. "19:00"), timezone (e.g. "America/New_York"), and day/week fields. This is a rule, not a timestamp, and it must stay in local time.
Why not UTC? A recurring rule like βevery Thursday at 7 PM Easternβ cannot be expressed as a fixed UTC time, because daylight saving moves it twice a year (23:00 UTC in summer, 00:00 UTC in winter). Collapsing the rule to a single UTC offset would silently shift every net by an hour across a DST boundary. Storing local-time + IANA zone and converting each computed occurrence to UTC is exactly how the iCalendar standard handles this (RFC 5545:
DTSTART+TZID+RRULE).
The rule that prevents reminder/scheduling bugs: calculate_schedule_dates() (in routers/ncs_schedule.py) projects naive local datetimes from the template rule. Any consumer that compares those projections against βnowβ must first convert with template_local_to_utc(template, dt) β never compare a naive local datetime against datetime.utcnow(). (This was the root cause of the June 2026 early/duplicate-reminder bug.)
NCS rotation is computed, not stored β and its anchor moves when the roster is edited.
There is no βwhose turn is nextβ pointer anywhere in the database. routers/ncs_schedule.py::compute_ncs_schedule() replays every occurrence from an anchor date forward, incrementing a counter once per occurrence, and assigns active_members[counter % len(active_members)]. get_rotation_anchor_date() picks that anchor from one of two sources:
NetTemplate.rotation_anchor_datewhen set β stamped bystamp_rotation_anchor()from every route that changes the rotationβs membership or order (add member, remove member, clear all, reorder, and the rotation-move step of a template merge).NetTemplate.created_atwhen it is null β the original behavior, so any template whose roster has not been edited since migration 061 computes exactly as it always did.
Why the anchor has to move. Both the divisor (len(active_members)) and the position-to-operator mapping change the moment a roster is edited, but a creation-date anchor does not. The result is a permanent phase offset between the order a manager just arranged and the order the arithmetic produces β it never self-corrects, so the only remedy was a manual per-occurrence swap, forever. Re-anchoring at the edit makes the first occurrence after the edit belong to position 1 and the cycle continue normally from there.
Two different flooring rules, deliberately. A created_at anchor is floored to local midnight, so a template created after that eveningβs net still counts that same-day occurrence as its first. A rotation_anchor_date is used at its exact time, so an edit made after tonightβs net already ran cannot retroactively claim that net. Anything new that mutates rotation membership or order must call stamp_rotation_anchor() in the same transaction, or it reintroduces the offset.
A roster edit alone does not touch a net that already exists β resync_pending_duty_ncs() is what does. _assign_duty_ncs() (ncs_reminder_service.py) stamps a NetRole(role="NCS", auto_assigned=True) exactly once, when a net is auto-created ~24h ahead of its scheduled time. Re-anchoring the rotation fixes every future computed occurrence, but a net created before the edit already has its NetRole committed, and nothing revisits it β reported by Joel Huntress (AA1GM) as a net launching with the pre-fix NCS still staffed. resync_pending_duty_ncs() (routers/ncs_rotation.py) closes that gap: every route that calls stamp_rotation_anchor() also calls this afterward, and it recomputes the duty NCS for any DRAFT/SCHEDULED net (future scheduled_start_time, not yet in LOBBY/ACTIVE) belonging to the template. It only ever replaces a NetRole that is itself auto_assigned=True β if a human has assigned, started, or self-checked-into that netβs NCS slot (auto_assigned=False), the net is skipped entirely rather than overwritten or duplicated. When the resync changes whoβs staffed, both the outgoing and incoming operator get a βschedule correctionβ email (EmailService.send_ncs_duty_correction).
Reminder windows must be one-sided, not Β±tolerance: NCSReminderService._in_reminder_window() (ncs_reminder_service.py) accepts reminder_hours - catch_up_hours <= hours_until <= reminder_hours β never earlier than the target lead time, only late enough to survive a missed poll tick. A symmetric abs(hours_until - reminder_hours) <= catch_up_hours window (the pre-2026-07-31 shape) fires on the first tick anywhere in that range, which in practice meant β1 hourβ reminders consistently went out ~90 minutes before start, verified against real production sends. Any new reminder tier should reuse this helper rather than reintroducing a symmetric window.
Comparing against a stored net instant: helpers that do arithmetic on Net.scheduled_start_time must not assume it is naive. SQLite returns naive values, PostgreSQL returns tz-aware ones, and mixing the two raises TypeError: can't compare offset-naive and offset-aware datetimes. Normalize both sides first β app/net_start.py::_as_naive_utc is the pattern (auto-lobbyβs fire-time math uses it).
Current storage caveat (see ROADMAP β βUTC-aware datetime hardeningβ): on SQLite, DateTime(timezone=True) does not actually persist an offset, so UTC instants are stored naive by convention. That is why several frontend call sites defensively append 'Z' before parsing, and why backend boundary helpers return naive UTC. This convention is fragile and would break under PostgreSQL (timestamptz returns tz-aware values); the roadmap item tracks standardizing on tz-aware UTC end-to-end.
Background polling and last_active
Any request the app makes on a timer must be marked as a background request, and any frequent poll should stop while the tab is hidden.
Mark the request. Spread BACKGROUND_REQUEST_CONFIG (frontend/src/services/api.ts)
into the axios call, which sets X-Background-Request: 1. The backend
(backend/app/dependencies.py) skips its last_active bookkeeping for those requests, so
that timestamp keeps meaning βthis operator did somethingβ, not βthis operator has a tab
openβ. Use it only for interval-driven calls β never for one caused by a click, a
navigation, or a form submit. The header is bookkeeping only and must never influence
authorization; tests/test_last_active.py pins that, along with the requirement that only
the exact value 1 counts.
Pause it while hidden. Use useVisibilityAwareInterval
(frontend/src/hooks/useVisibilityAwareInterval.ts) instead of a bare setInterval. Browsers
throttle timers in hidden tabs but donβt stop them, so a plain interval keeps hitting the
backend all night for nobody. The hook clears the interval when the tab is hidden and refetches
immediately on return, so what the operator sees when they come back is fresh either way.
Never do this to the net WebSocket. An NCS with the net in a background tab still needs live events; only polling pauses.
Why this exists: the navbar traffic-inbox badge polls every 60s from every page, and every
authenticated request used to stamp last_active. One real user therefore showed as
active-within-the-minute continuously from the day they registered β a tab left open, not a
person at the keyboard β which silently defeated the pre-deploy βis anyone using prod right
nowβ safety check. Note the net-view βOnlineβ indicator is unaffected either way: it comes from
live WebSocket connections (ConnectionManager.get_online_users), not from last_active.
WebSocket
Endpoint: WS /api/ws/nets/{net_id}?token=<jwt>
ConnectionManager in main.py tracks connections per net and broadcasts to every
socket on that net. Every message has the shape type / data / timestamp, plus a
user_id on relayed messages.
Messages come from two directions, which is easy to miss when tracing a bug:
Server-originated β a route handler calls manager.broadcast(...) after a database
write. These are the authoritative events:
| Type | Emitted by |
|---|---|
status_change |
routers/check_ins.py β a stationβs status changed |
check_in_deleted |
routers/check_ins.py |
hand_raised_changed |
routers/check_ins.py |
net_started / net_lobby_opened |
routers/nets_core.py β one call site picks between them based on whether the NCS opened the lobby or went straight live. net_lobby_opened is also emitted by app/net_start.py::auto_open_lobby for a scheduler-opened lobby, where started_by is null because no human opened it. Both are handled in hooks/useNetWebSocket.ts |
net_status_change |
routers/nets_core.py (net closed) and routers/nets_export.py (net archived/unarchived) |
net_pause_change |
net_pause.py |
role_change |
routers/nets_roles.py |
can_hear_changed |
routers/can_hear.py β a βwho can this station hear?β report was saved |
chat_message, chat_reaction, chat_image |
routers/chat.py |
traffic_logged |
routers/traffic_forms.py β a form was filed on this net (net_id set); the per-net Traffic panel refetches its list and summary. Only fires when the form has a net; a standalone form has no connection group to notify |
traffic_log_changed |
routers/traffic_log.py β a chain-of-custody hop was appended to a form on this net (net_id set); the Traffic panel and the inbox badge refetch. Same not-fired-for-standalone-forms rule as traffic_logged |
ping |
main.py keepalive |
Client-originated (relayed) β the socket handler in main.py sanitizes whatever a
client sends and rebroadcasts it verbatim to the net, defaulting the type to message.
The server does not generate or validate these; they are peer-to-peer nudges telling
other clients to refetch:
| Type | Sent by | Effect on receivers |
|---|---|---|
check_in |
components/netview/checkInActions.ts |
refetch the check-in list |
active_speaker |
checkInActions.ts |
highlight the speaking station |
active_frequency |
checkInActions.ts |
refetch the net |
Because relayed events are client-generated, a client that never sends one (or drops offline mid-action) leaves other clients stale until their next poll or refetch. Do not treat them as guaranteed delivery of a state change β the REST write is the source of truth, and the relay is only a hint to go read it.
useNetWebSocket.ts is the single frontend consumer and handles both sets.
Guest connections and PII. The WS endpoint accepts connections with no token
(user_id becomes the guest sentinel 0) so an anonymous viewer gets live updates on a
publicly-shared net, same as the REST reads. ConnectionManager.broadcast takes an optional
guest_message β when given, connections with user_id == 0 receive it instead of message.
chat.py::create_message is the one caller today: it sends the raw chat text to authenticated
connections and a copy scrubbed by redact_contact_info (see app/utils.py) to guests, so a
live guest canβt see a phone number or email arrive in real time that the REST GET
/chat/nets/{id}/messages would have redacted. Any future server-originated message type that
carries free text a user typed (not just structured state) needs the same treatment.
Reconnect and resync
Broadcasts are fire-and-forget. ConnectionManager sends to whoever is connected at
that instant and keeps no per-client history, so every event emitted while a client
is disconnected is lost to that client permanently. Nothing replays it.
This matters more than it sounds. A reconnected socket looks healthy, so a stale page shows no symptom β the operator sees a normal-looking net that is quietly missing check-ins, status changes, chat, and traffic. For ARES and SKYWARN deployments, where a link dropping mid-net is routine, a silently stale log is the worst available failure mode.
useNetWebSocket.ts therefore does three things:
- Reconnects indefinitely. Exponential backoff (3s, 6s, 12s β¦ capped at 30s) with no attempt ceiling. It previously gave up after 10 tries (~3.75 minutes), which left a dead page that still looked live. The effect cleanup stops retries when the page or net goes away, which is the only thing that should stop them.
- Reconnects immediately on the browserβs
onlineevent, cancelling any pending backoff, so a link that returns doesnβt wait out a 30-second timer. - Resyncs on every reconnect but not the first connect.
hasConnectedRefdistinguishes them β the initial mount already fetches throughuseNetData, so resyncing there would just double every request.
The resync refetches net, check-ins, roles, stats, and can-hear reports directly, then
dispatches a netResync window CustomEvent with detail: { netId }.
netResync is the contract for any panel that owns its own data. Panels that fetch
independently canβt be refreshed from the hook, so they listen for this event β the
same relay convention already used for newChatMessage and trafficLogged. Current
listeners:
| Listener | Refetches |
|---|---|
components/Chat.tsx |
the message thread |
components/ActivityLog.tsx |
the netβs system/activity messages |
components/netview/TrafficPanel.tsx |
the per-net traffic list and summary |
components/traffic/TrafficDetail.tsx |
the open formβs chain-of-custody timeline |
hooks/useTrafficInbox.ts |
the inbox badge count |
If you add a panel that fetches its own data and updates from a WebSocket event, add
a netResync listener at the same time. A panel that handles live events but ignores
resync is exactly the silent-hole bug this section exists to prevent. Filter on
detail.netId where the panel is net-scoped; refetch unconditionally where it isnβt
(the inbox badge, an open form detail). Refetch wholesale rather than reasoning about
what was missed β every list involved dedupes by id, so a full refetch merges cleanly
and is far easier to get right than a diff.
Do not βoptimizeβ this into an incremental catch-up (fetch only what is newer than the last known id). Chat supports deletion and reactions, and check-ins can be deleted and edited, so an incremental fetch would silently never remove a deleted message or apply an edit made during the outage. The full refetch is a correctness requirement, not laziness.
Frontend build-version detection
A tab left open across a deploy keeps running the JS it loaded with β nothing tells it
a new build exists, and content-hashed asset filenames mean a stale tab canβt even
fetch the new bundle by accident. Left alone this produces two symptoms: bug reports
that turn out to be an old client, and (worse) admin metrics skewed by old clients
missing newer instrumentation, e.g. the X-Background-Request header did not exist
before 2026-08-11, so any tab still running pre-that-date JS stamps last_active on
every background poll and never stops looking βactive.β
How it works:
vite.config.tscomputesgetBuildId()β the short git commit SHA, not a timestamp. A backend-only deploy that rebuilds the frontend from an unchanged commit must produce the identical id, or every deploy would falsely tell open tabs a new frontend shipped when nothing about the frontend actually changed.- The id is embedded in the JS bundle via
define: { __BUILD_ID__ }(that tabβs own version, fixed at load time) and separately written todist/version.jsonby thewrite-version-filepluginβscloseBundlehook (the serverβs current version, fetched fresh on every poll). These must stay two different mechanisms β embedding the serverβs current id in the same hashed bundle a stale tab is running would never update. hooks/useBuildVersion.tsfetches/version.jsonwithcache: 'no-store'β this depends on production sendingCache-Control: no-cacheon non-hashed static files (Caddyfile), otherwise a browser-cached copy ofversion.jsonitself defeats the check. Polls viauseVisibilityAwareInterval(5 minutes; a stale build is never as time-critical as the 10s maintenance banner, and this never auto-reloads, so thereβs no benefit to a tighter interval).components/UpdateAvailableBanner.tsxshows a dismissibleinfobanner with a Reload button when the ids differ. Never auto-reload β an operator mid check-in entry would lose it. Dismissal is keyed to the build id it was shown for, so the next deploy shows the banner again even if a previous one was dismissed.
Chat and the activity log are two panels rendering the same endpoint, so both refetch
it on resync. They cannot share state β a popped-out panel is a real window.open
document with its own React root (usePoppedOutWindow.ts), so no context spans them.
chatApi.list therefore coalesces concurrent in-flight requests per net in
api/chat.ts, handing each caller its own copy of the array. Deliberately scoped to
that one call; a blanket GET cache would change behavior for callers that legitimately
expect an independent read.
What this does not do: it does not let anyone keep working while disconnected. Writes attempted offline fail, the optimistic status paint rolls back, and the change is discarded rather than queued. Durable offline operation is a separate roadmap item (see ROADMAP.md, βOffline-Capable Web Clientβ) and needs client-generated IDs, an IndexedDB queue, and a conflict rule before it is safe.
Enabling and disabling outbound email
EMAIL_ENABLED in backend/.env is the master send switch. It defaults to
true, so production and fresh installs behave normally without setting it.
To disable sending (the correct state for alpha and beta):
# in backend/.env
EMAIL_ENABLED=false
then restart the backend (sudo -n systemctl restart ectlogger). Every send
becomes a no-op: _send_suppressed() in app/email/base.py logs the intended
recipient and subject and returns, and the caller carries on as if delivery
succeeded. Nothing raises, so net closes, user creation, and the reminder
service all behave exactly as they do in production.
Confirm it is off by watching the log during an action that sends mail:
journalctl -u ectlogger -f | grep EMAIL
# expect: [EMAIL] Suppressed email to someone@example.com (EMAIL_ENABLED=false): <subject>
To temporarily enable sending on a test instance β for example to verify a
new template actually renders in a mail client β set EMAIL_ENABLED=true,
point the SMTP_* settings at a real relay, restart, run the one action you
need, then set it back to false and restart again. While it is on, that
instance will mail whatever real addresses its database holds; betaβs database
contains real user rows, so prefer a net or template whose only subscriber is
your own address.
Why this exists as well as the SMTP setting: beta has long pointed SMTP_HOST
at 127.0.0.1 so connections are refused. That works, but it is failure-based
protection β the message is fully composed and the real recipient list resolved
before anything stops it, and a single SMTP_HOST edit would start delivering
to real users with nothing else in the way. EMAIL_ENABLED=false stops the
send before any connection is attempted and is independent of SMTP config, so
the two together are belt and braces. Keep both in place on alpha and beta.
Changelog (user-facing)
frontend/src/changelog.json is the single source of truth. Both the
in-app ChangelogNotification.tsx dialog and the whats_new_service.py digest
email read from this file. See docs/DESIGN.md for entry format rules.
Always run date before writing a changelog entry. Todayβs date (America/New_York):
date
Deployment
See docs/PRODUCTION-DEPLOYMENT.md for full deploy steps.
Quick reference:
# Push, pull on prod, build frontend, restart
git push origin main
ssh ectlogger@app.ectlogger.us "cd ~/ectlogger && git pull origin main"
ssh ectlogger@app.ectlogger.us "cd ~/ectlogger/frontend && npm run build"
ssh ectlogger@app.ectlogger.us "sudo -n /usr/bin/systemctl restart ectlogger"
ssh ectlogger@app.ectlogger.us "sudo -n /usr/bin/systemctl is-active ectlogger"
Passwordless sudo on production covers only: restart ectlogger, is-active ectlogger,
status ectlogger, journalctl -u ectlogger *, and Fail2Ban client commands.
Any other sudo operation (daemon-reload, service file edit) requires the ectlogger
account password.
Environments
| Name | Host | Python | Notes |
|---|---|---|---|
| Production | ectlogger@app.ectlogger.us |
3.11.2 | Caddy, static build, port 8001 |
| Beta | bradb@10.6.26.3 |
3.13 | Backend: uvicorn port 8000, auto-reload. Frontend: vite preview (static build) port 3000 β not a dev server, npm run build required after frontend changes |
| Alpha | bradb@10.6.26.6 |
3.13 | Feature testing before beta; frontend serving mode unverified β check with ps aux \| grep vite before assuming HMR |