- Python 98.1%
- Nix 1.9%
| src/crewsense | ||
| tests | ||
| .gitignore | ||
| discover.py | ||
| flake.lock | ||
| flake.nix | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Force-Hold Eligibility Report
Generates a ranked list of who is eligible to be force-held onto the next shift at
an 08:00 changeover, pulled live (read-only) from the CrewSense / Vector API. Output is a
terminal table split into four force-lists: Firefighter · Medic · Captain · B1/EMS1,
each ordered by the department's forced-holdover rotation list (scraped from the
CrewSense ControlPanel — the crewsense lists subcommand).
Setup
Requires uv — uv run crewsense … in the project directory
sets up the environment automatically (uv sync to do it explicitly).
API credentials come from environment variables only (never hard-coded or passed as command-line arguments):
export CREWSENSE_CLIENT_ID=...
export CREWSENSE_CLIENT_SECRET=...
The forced-holdover rotation lists are not in the API (verified against the OpenAPI
spec and by probing undocumented endpoints — /v1/lists is the OT callback lists, without
membership). They live only in the session-authenticated ControlPanel web app, so they are
scraped. One-time login (password prompted, never stored; only the session cookies are
saved, to ~/.cache/crewsense/cookies.txt; raw cookie headers are not accepted):
uv run crewsense login
Read-only. Every data call is a GET; the only POSTs are the OAuth token exchange and the interactive web login. The API key has admin rights on a production system — do not add write calls.
Usage
uv run crewsense report # next 08:00 changeover
uv run crewsense report --date 2026-07-03 # replay that changeover's archived report
uv run crewsense report --debug # also print why each person was skipped
uv run crewsense report --who smith # dump one person's titles, quals & segments
uv run crewsense report --no-force # skip the force-list rankings
uv run crewsense report --force-json f.json # rankings from a saved `lists --json` dump
uv run crewsense report --html # email-ready HTML table on stdout
uv run crewsense report --html report.html # ... or written to a file
uv run crewsense report --mime bc@dept.gov | msmtp -t # complete email message
uv run crewsense lists # just print all four force lists
uv run crewsense lists --list 205 --json # one list, machine-readable
uv run crewsense lists --html saved.html # parse a browser-saved page (offline)
Reading the table: each entry is #N Name <capacity>h, where #N is the person's
position on that column's forced-holdover list (1 = next up to be forced; #? = eligible
but not found on the list) and capacity is the hours they can be held (up to the 48h cap).
Markers: ! = ≤8h left (limited hold), (OT) = on overtime/comp at 08:00 (sorted below
regular), (acting) = added to that column by qualification (sorted at the bottom; its
#N is the person's primary FF/Medic list position). If no force-list data is available (no session yet, or --no-force), the #N
markers disappear and columns fall back to alphabetical order.
--html emits the same table as an HTML fragment with all styles inlined on the
elements, so it survives email clients (Gmail et al. strip <style> blocks): paste it
into an HTML email body, or send it directly as a text/html MIME part.
--mime TO goes one step further and emits a complete email message on stdout —
multipart/alternative with the plain-text terminal table as the fallback and the HTML
table as the preferred part — ready to pipe into msmtp -t (or any sendmail-compatible
MTA), which reads the recipients from the headers. --subject and --mail-from
override the defaults; without --mail-from, msmtp fills in the From header from its
own config, so a cron line stays as simple as:
0 6 * * * crewsense report --mime bc@dept.gov | msmtp -t
Report snapshots (--date)
Both inputs to a report are living state: the forced-holdover lists rotate the morning
someone is forced, and the schedule itself gets edited day-of — so a past changeover's
report can't be re-created from live data. Instead, every live run archives its final
ranked report as a JSON snapshot filed under the changeover it feeds (the last run
before each 08:00 changeover wins; a run whose force lists were unavailable never
overwrites a ranked snapshot). --date — for any date, including the current one —
replays that day's snapshot — no API credentials or ControlPanel session needed — and
errors if none exists; only a bare crewsense report runs live. This means today's
report can be re-rendered (e.g. as --html after a terminal run) without re-scraping. --html/--mime work on replays; --who/--debug/
--no-force/--force-json need live data and don't. Snapshots are kept for a year in
$STATE_DIRECTORY/snapshots (systemd) or $XDG_STATE_HOME/crewsense/snapshots,
overridable with --snapshot-dir.
Eligibility rules
A person is listed if all hold:
- Working an eligible unit right up to 08:00.
- Not already scheduled to start the next shift at 08:00.
- Under the 48h cap — consecutive real-work hours in the current on-duty period
< 48. Capacity =48 − hours worked. - Not carrying a disqualifying shift label (
Medic Student).
Columns are force-lists per vacancy type; a person can appear on more than one:
- Firefighter / Medic / Captain — by the person's rank (from titles), not the seat worked.
- B1/EMS1 — by position: whoever staffs a
B#,EMS#, or spelled-outBattalion #unit. - Bottom of Captain: eligible FFs/medics holding the
Captainqualifier →(acting). - Bottom of B1/EMS1: eligible medics holding the
EMS 1qualifier →(acting).
Within a column: native placements first, then qualification-adds; regular before OT/comp; by forced-holdover list position within each group (unranked people last), alphabetical (by last name) as the tiebreaker. Two special rules:
- Acting adds rank by their primary list: there is no acting rotation, so multiple
(acting)entries order by the person's position on their own rank's list — a FF by the Firefighter list, a medic by the Medic list. Their#Nmarker shows that primary position. - BCs (Bxxx) are hours-eligibility only: the BC on
B1/B102/Battalion 102appears in B1/EMS1 when under 48h, but there is no BC forced-holdover list (no acting BCs), so they're listed unranked (no#marker) after the column's ranked natives.
Force-list → column mapping (by keyword in the ControlPanel list name, EMS checked
before Captain): Firefighter Forced Holdover → Firefighter, Medic Holdover list →
Medic, Captain Forced Holdover → Captain, EMS Captain Forced Holdover → B1/EMS1.
People are matched between the schedule API and the scraped lists by normalized name
(case/whitespace-insensitive).
Assumptions / rules encoded
These were confirmed against live data and are the config constants at the top of
src/crewsense/eligibility.py (force-list → column mapping in report.py) — adjust
there if the department changes.
- Eligible units: Engine
E#, MedicM#, ATAT#, LadderL#, BattalionB#/ spelled-outBattalion #, EMSEMS#. Excluded:CP*,Training/Meetings,Modified Schedule, relief/debit pools, OT sign-up pools. - 48h limit: real duty counts (line units, relief/debit, awarded OT, comp, forced holds).
Training/Meeting hours never count — they bridge continuity but are excluded from the
total (so a meeting overlapping a full shift neither inflates nor cuts capacity).
OT sign-ups (
OT Candidate/ the OT pools) are not on duty at all and are ignored. - "Already on next shift" counts only a start at 08:00 on a real line unit. It ignores Training/Meeting blocks, OT sign-ups, and forced holds — a forced next-shift segment is the outcome the report predicts, so forced people still appear (lets you validate past shifts).
- Rank: from
/v1/users/{id}/titles(FF/Captain/Medic/BC…). BCs map to no column. - Acting/EMS captain: from
/v1/users/{id}/qualifiers— theCaptainandEMS 1qualifiers. Real captains carryCaptaintoo but are already native to the column, so they're not duplicated. (Note: a stale qualifier left on a promoted profile will still add them — keep profiles clean.) - Changeover defaults to the next 08:00 (today if run before 08:00, else tomorrow);
any
--datereplays that changeover's archived report snapshot (see above). - Ladders/B/EMS generalized to
L#/B#/EMS#, so future units (e.g.B2,EMS2) work automatically.
Layout
The pipeline is fetch → evaluate → rank → render; renderers are deliberately separate
from the data model so new outputs (daily email to the on-duty BC, a static web page)
can be added as new modules under render/ plus a subcommand, without touching the rules.
| Path | Purpose |
|---|---|
src/crewsense/api.py |
v1 API client (OAuth, /schedule, titles/qualifiers). |
src/crewsense/eligibility.py |
The rules engine + all department config constants. |
src/crewsense/forcelists.py |
ControlPanel session (login/cookies) + force-list scraper. |
src/crewsense/report.py |
Glue: force-list → column mapping, rankings loader, changeover resolution. |
src/crewsense/snapshots.py |
Daily report snapshots: live runs archive the final ranked report; --date replays them. |
src/crewsense/render/terminal.py |
The terminal table (future: email.py, html.py). |
src/crewsense/cli.py |
crewsense report / lists / login subcommands. |
discover.py |
Standalone read-only API inspector — re-run if unit / work-type / title / qualifier names change, to re-confirm the mappings. |
tests/test_force_holds.py |
Offline regression suite (no network). |
Testing
uv run tests/test_force_holds.py # exits non-zero on any failure
Runs the full eligibility/ranking logic against a synthetic schedule shaped like the real
/schedule response, with /titles and /qualifiers stubbed. Covers every edge case found
during development: the 48h cap and gap-reset, prior-day OT signups, forced-onto-next-shift,
mid-shift meetings vs. meetings overlapping a full shift, the Medic Student label, the
position-based B1/EMS1 column, and the acting/EMS-captain qualification adds.
To debug a specific real person, use --who <name> (prints their titles, qualifiers, and
every schedule segment with classification tags).