JQC WEB — phase44: scheduled inspection End Date
================================================
Repo: lt_janitorial_quality_control
Deploy root: /home/jqc/janitorial_qc/
WEB ONLY. No iOS changes, no iPad rebuild.

NEW FILE
  migrations/versions/phase44_scheduled_end_date.py
      revision      = 'phase44_sched_end_date'
      down_revision = 'phase43_sched_recurrence'   <- verified current HEAD

OVERWRITE
  app/models/scheduled_inspection.py      end_date column; is_within_end_date();
                                          is_expired; expire_if_past_end_date();
                                          fulfill() deactivates past the boundary
  app/utils/forms.py                      end_date DateField + validate() rules
  app/routes/scheduled_inspections.py     _apply_recurrence() sets/clears end_date;
                                          _reject_if_past_end_date() guard;
                                          per-context next_due_date label;
                                          run_reminders() expiry sweep; audit detail
  app/api/scheduled.py                    'end_date' in _scheduled_payload
  app/templates/scheduled_inspections/form.html   End Date field + JS toggle
  app/templates/scheduled_inspections/list.html   "Ends" column + "Ended" badge
  CLAUDE.md

DEPLOY  (migration step first, then code)
  cd /home/jqc/janitorial_qc
  git pull

  # 1. MIGRATION
  source venv/bin/activate
  flask db current            # expect phase43_sched_recurrence
  flask db upgrade
  flask db current            # expect phase44_sched_end_date

  # 2. CODE
  sudo systemctl restart janitorial_qc
  sudo systemctl status janitorial_qc --no-pager

ROLLBACK
  flask db downgrade phase43_sched_recurrence     # drops end_date, nothing else

VERIFY
  1. New Schedule -> frequency "One-time": End Date row is HIDDEN,
     date field reads "Start Date"
  2. Switch frequency to Weekly: End Date row appears
  3. Edit an existing schedule: date field reads "Next Due Date"
  4. Validation:
     - end date before the due date            -> rejected
     - end date on a one-time schedule (via curl/devtools) -> rejected
     - Mon/Wed/Fri, pick a Tuesday, end date that same Tuesday
       -> rejected, message names the Wednesday
  5. List: "Ends" column shows the date, "No end" when blank, "—" for one-time
  6. Existing schedules: unchanged, "No end", still Active, cadence identical
  7. Boundary: set end date = next due date, complete the inspection
     -> schedule goes Inactive, badge reads "Ended"
  8. Cron sweep:
     curl -X POST "https://jqc.ltservicesinc.com/scheduled-inspections/run?token=$DIGEST_SECRET"
     -> JSON now includes "expired": N
     -> a schedule past its end date that was never completed goes Inactive
        and stops generating overdue alerts

NOTES
  - Migration follows the phase42/phase43 idiom in this repo
    (op.get_bind() + sa.text() + INFORMATION_SCHEMA). Flag if you want the
    stricter plain-string-only form instead; 62 existing migrations use this one.
  - api/scheduled.py now returns "end_date". Additive and safe: the iPad
    decodes explicit CodingKeys, so current builds ignore the new key.
