No description
  • Python 98.1%
  • Nix 1.9%
Find a file
2026-07-09 16:33:48 -07:00
src/crewsense --date flag only replays snapshots for that date. No live report is run. 2026-07-09 16:33:48 -07:00
tests --date flag only replays snapshots for that date. No live report is run. 2026-07-09 16:33:48 -07:00
.gitignore Update flake formatting and nixpkgs url. Ignore direnv files 2026-07-07 07:43:41 -07:00
discover.py Restrict credentials and sessions to safer sources 2026-07-06 17:20:17 -07:00
flake.lock Update flake formatting and nixpkgs url. Ignore direnv files 2026-07-07 07:43:41 -07:00
flake.nix Update flake formatting and nixpkgs url. Ignore direnv files 2026-07-07 07:43:41 -07:00
pyproject.toml Restructure into a crewsense package; add forced-holdover list ranking 2026-07-06 16:06:16 -07:00
README.md --date flag only replays snapshots for that date. No live report is run. 2026-07-09 16:33:48 -07:00
uv.lock Add json snapshots of force hold list to compute prior date eligibility lists 2026-07-09 05:35:39 -07:00

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 uvuv 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:

  1. Working an eligible unit right up to 08:00.
  2. Not already scheduled to start the next shift at 08:00.
  3. Under the 48h cap — consecutive real-work hours in the current on-duty period < 48. Capacity = 48 hours worked.
  4. 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-out Battalion # unit.
  • Bottom of Captain: eligible FFs/medics holding the Captain qualifier → (acting).
  • Bottom of B1/EMS1: eligible medics holding the EMS 1 qualifier → (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 #N marker shows that primary position.
  • BCs (Bxxx) are hours-eligibility only: the BC on B1/B102/Battalion 102 appears 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#, Medic M#, AT AT#, Ladder L#, Battalion B# / spelled-out Battalion #, EMS EMS#. 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 — the Captain and EMS 1 qualifiers. Real captains carry Captain too 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 --date replays 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).