No description
  • Python 98.5%
  • Nix 1.5%
Find a file
2026-10-12 08:19:36 -07:00
src/crewsense Warn when scheduled work at 08:00 pushes someone past 48h. 2026-09-18 10:07:24 -07:00
tests Warn when scheduled work at 08:00 pushes someone past 48h. 2026-09-18 10:07:24 -07:00
.gitignore Update flake formatting and nixpkgs url. Ignore direnv files 2026-07-07 07:43:41 -07:00
discover.py Support python 3.11-3.15. Drop unmaintained versions. 2026-10-12 08:19:36 -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 Support python 3.11-3.15. Drop unmaintained versions. 2026-10-12 08:19:36 -07:00
README.md Warn when scheduled work at 08:00 pushes someone past 48h. 2026-09-18 10:07:24 -07:00
uv.lock Support python 3.11-3.15. Drop unmaintained versions. 2026-10-12 08:19:36 -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 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. Logging in is automatic — there is no login subcommand. The ControlPanel credentials come from the environment (never hard-coded or passed as arguments):

export CREWSENSE_USERNAME=...
export CREWSENSE_PASSWORD=...

CrewSense invalidates sessions server-side on its own schedule (observed lifetime: about two weeks). When a run finds no working session, it logs in on the spot and saves the session cookies (to ~/.cache/crewsense/cookies.txt; raw cookie headers are not accepted), reusing them until they expire. The login source, in order:

  1. CREWSENSE_USERNAME / CREWSENSE_PASSWORD — e.g. rendered into the service's EnvironmentFile by sops-nix. This is what unattended runs (the daily systemd report) use. One attempt only; a failed attempt (rotated password, new 2FA step) is a hard error that fails the unit, never a silently unranked email.
  2. An interactive prompt — only when those variables are absent and stdin is a TTY (password via getpass, never stored). This covers a one-off crewsense lists from a terminal on a machine without the secrets.

crewsense lists needs the force lists, so with neither of those it fails. crewsense report instead degrades: it still emails the eligibility table, but with columns in alphabetical order and a loud red banner across the top saying the ordering is not the forcing order (see below) — a sops hiccup never silently kills the daily report.

Read-only. Every data call is a GET; the only POSTs are the OAuth token exchange and the ControlPanel login (environment credentials or the TTY prompt). 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 (login failed/unavailable, --no-force, or replaying an unranked snapshot), the #N markers disappear, columns fall back to alphabetical order, and the report is topped with a loud red banner warning that the ordering is alphabetical and must not be used to decide who to force.

--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 work period < 48. Only a break of 8+ hours off operational work resets the period; a shorter gap (e.g. a 1h sick-leave hole in a shift) keeps it continuous but doesn't count as worked time. Training/Meetings time counts as time off for that break. Capacity = 48 − hours worked.
  4. Not carrying a disqualifying shift label (Medic Student).
  5. Not scheduled in a Force-worthy-labeled Training/Meetings block at 08:00 — the department's critical admin work (recruit training, specialty ladder / fire boat training, …). This only blocks the hold; those hours still never count toward the 48h total, and unlabeled Training/Meetings blocks never block a hold.
  6. Not in the Employees Off block at 08:00 with a force-blocking time-off type: Sick Leave, Sick on Exchange, FMLA, HR Approved Med Leave, Supplemental, Bereavement, Admin, Jury Duty, Military. All other time off (Regular STO, Vacation, Comp Time Off, Schedule Change Off, Holiday, Juneteenth, Unbid Vacation) leaves the person force-eligible. Subtypes don't matter. Blocking time off that starts later in the next shift doesn't bar the hold; it caps capacity at the hours until it starts (Vacation at 08:00 → FMLA at 12:00 = a 4h hold).

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). Only an 8+ hour break from operational work resets the work period — a shorter gap (a sick-leave hour, a mid-shift hole) bridges it, so the period can span more than 48h of wall clock (live runs fetch 96h of schedule history to see all of it). Training/Meeting time is not operational: it never counts toward the total and counts as time off for the 8h break (so 8h+ of training between shifts resets the period, and a meeting overlapping a full shift neither inflates nor cuts capacity). This includes Force-worthy blocks. OT sign-ups (OT Candidate / the OT pools) are not on duty at all and are ignored.
  • Over-48h warning: above the table, the report flags anyone scheduled for real work starting at 08:00 (forced holds included; Training/Meetings and OT sign-ups not) whose hours worked up to 08:00 plus that continuing next-shift work exceed 49h — e.g. a trade, or a forced hold placed on the schedule after the previous report. The extra hour is deliberate: coming in an hour early and then working 48 is common and overlooked. The warnings are archived with the snapshot, so --date replays show them.
  • "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).
  • Force-worthy training is the one exception: a Training/Meetings block carrying that label and covering 08:00 makes the person unforceable (--debug: "in Force-worthy training at 08:00"; --who tags the block force-worthy). The label is matched case-insensitively, ignoring spaces/hyphens, and only on the Training/Meetings unit; a Force-worthy block later in the shift has no effect.
  • Time off (the schedule's time_off list, shown as "Employees Off") blocks a hold when its type is in FORCE_BLOCKING_TIME_OFF (matched by type name or work code, case-insensitively) and covers 08:00. If it instead starts later in the next shift (08:00 → 08:00), the person stays eligible with capacity capped at the hours until it starts. Time off on the prior shift (e.g. a sick-leave hour) doesn't block. --debug shows e.g. "off on Sick Leave 09-17 08:00-18:00 (next shift)"; --who lists time off tagged force-blocking. The department's list deliberately differs from CrewSense's own force_include flag on /time_off_types, which marks Jury Duty as force-eligible.
  • 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 (auto 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 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 the 8h-break reset (sub-8h gaps like a sick-leave hour bridge the period; 8h+ of Training/Meetings resets it), prior-day OT signups, forced-onto-next-shift, the over-48h warning, mid-shift meetings vs. meetings overlapping a full shift, the Medic Student label, Force-worthy training at 08:00 vs. mid-shift, blocking vs. force-eligible time off, 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).