The PrEP Cycle
Every PrEP patient on our telehealth platform repeats three things, roughly every 90 days:
When all three are current, the patient is up-to-date. When any of them lapses, we need to get them back on track.
These three activities have independent timelines. A patient might finish intake on day 1, get labs back on day 20, and receive their prescription on day 35. By the time intake is due again at day 90, labs are only on day 70 and the prescription on day 55.
What Went Wrong
We modeled PrEP renewal as a single linear stream: intake, then labs, then prescription, wait 90 days, repeat. Everything hung on the "ideal renewal date," a field called prep_renewal_date on the Visit model. It's calculated by determine_ideal_renewal_date_from_meds(): earliest_fill_date + (quantity × (1 + refills)).
The assumption was that these events would happen in order, close together. Every automated reminder radiated from that single prescription-based anchor. A Celery beat task (send_prep_renewal_reminders) runs daily at 17:00 UTC, counts days until prep_renewal_date, and fires a drip sequence at 29, 19, 15, 7, and 1 days before.
prep_renewal_date Timeline (actual messages)prep_renewal_dateprep_renewal_date countdown
Tโ40 days
Meanwhile: Kit return reminders (a separate system)
A separate Celery task (send_kit_return_reminders, daily at 15:11 UTC) fires reminders based on days since kit delivery:
Suppressed when kit_shipped_to_lab_date is set (kit already returned) or when prep_renewal_date is >30 days away. That second condition means kit reminders are also tied to the prescription timeline.
This model fell apart when it hit reality:
The renewal drip (at 29, 19, 15, 7, and 1 days before prep_renewal_date) sends messages like "Action Required: It's time for your prescription refill" and "Please complete your intake forms." But prep_renewal_date is calculated from the prescription fill date, not when the patient last completed intake. A patient who finished intake two weeks ago still gets told to fill out forms because their prescription anchor says it's time.
Kit return reminders fire at 3, 5, 7, 10, and 15 days after delivery. But providers routinely ship kits proactively, telling patients "hold onto this for a month or two." Three days later the system sends: "Your at-home test is waiting for an adventure! ✈ Mail it off so we can get your prescription going!" This directly contradicts the provider. And kit reminders are suppressed when prep_renewal_date is more than 30 days away, tying them to the prescription timeline too.
The prep_renewal_date drip fires as the prescription runs down, but every message it sends is about intake forms, not the prescription itself. Nobody tells the patient "your pills are running out." The EmrMedication model knows the fill date, quantity, and refills, but no message uses that data to say something specific about the prescription. Providers can see it in the status indicators, if they remember to check.
Each of these three activities has its own timeline. Our notification systems are cross-wired: the prescription date triggers intake reminders, kit delivery dates trigger return reminders regardless of what the provider said, and prescription lapses trigger nothing at all.
PrEP Status Indicators
To address the visibility gap, we built PrEP Status Indicators: three badges rendered in the provider's patient header via PrepStatusIndicators.jsx. The backend method get_prep_status_indicators() returns days_since_last_intake, days_since_last_hiv_result, and days_since_last_prep_prescription for each telehealth patient.
getStatusClass() turns badges yellow at 60 days and red at 90. Intake measures days since the last telehealth visit (checkin_datetime), labs since the last released HIV result (result_datetime), and Rx since the last PrEP medication fill (earliest_fill_date). Drag the slider above to see them drift apart.
This gave providers visibility, but it's read-only. No notifications go to patients. No workflows are triggered. Providers have to manually check each patient and reach out one by one.
The Three-Track Model
The solution: three parallel care plans, one per track, each with its own cycle and reset trigger. Each track counts days since its last completion and sends escalating messages as the patient approaches and passes the 90-day mark.
When the corresponding event happens (a new visit, a new lab result, a new prescription), that track's cycle resets to day 1. The other tracks keep ticking, unaffected.
Notice how pressing one trigger button resets only that track. The other tracks keep counting independently. Messages (the small markers on each bar) only appear on the track that needs attention, and only after day 60.
Why not one care plan with three sections?
Each track resets independently. If we used a single care plan, resetting intake would also reset the labs and prescription timelines. The whole point is that these are independent clocks that only reset when their specific event occurs.
The prescription track also has variable cycle lengths (30/60/90 days depending on prescription duration), while the others are always 90 days. Separate care plans let each track run its own schedule.
Transactional vs. Drip
This system draws a clear line between two categories of patient communication:
⚡ Transactional (event-driven)
- "Your test kit is now on its way to you! Here's your USPS tracking ID..."
- "New results are available in your Healthvana account"
- "You have an appointment scheduled at..."
- Fires immediately when an event happens
- Responds to the present
🕑 Drip (care plan, time-driven)
- Day 60: "It's time to schedule your renewal"
- Day 75: "Your labs are due. Time to complete your test kit."
- Day 90: "To keep your PrEP prescription active, we need your labs. Let us help."
- Escalating urgency over time
- Responds to the passage of time
The care plan should never duplicate or contradict a transactional message. If a patient has a test kit in transit, care plan messages are suppressed. The patient has already done their part. The cycle keeps ticking, but messages only go out when the patient genuinely needs to act.
How Suppression Works
The proposed LabKitAwareHandler checks the patient's AshLabKit status before sending. If a kit is in an active state (in_transit_to_lab, samples_received, accessioned), the care plan message is suppressed. The patient is already in motion. Here's a scenario: the patient returns their kit on day 65, but the lab reports "needs new sample" on day 75.
Day 60's reminder fires normally. AshLabKit status is still delivered_to_patient, so the patient needs to act. On day 65 the patient returns the kit, moving it to in_transit_to_lab. The day 70 reminder is suppressed because the LabKitAwareHandler sees the kit is in an active state. On day 75 the lab reports rejected (needs new sample), so the handler sees no active kit and day 80's reminder goes out. The patient needs to act again.
How It Maps to the Engine
We already have a production care plan system, and it supports almost everything we need out of the box.
The Model
For each patient, we create instances:
The 5-Line Reset
Resetting a track is dead simple. This pattern already exists in lib/build_care_plan.py and registration/dreg/form_actions.py:
cycle = CarePlanCycle.objects.get(slug=cycle_slug)
pc = PatientCycle.objects.create(
patient=patient,
patient_care_plan=patient_care_plan,
cycle=cycle,
start_datetime=now(),
coordinator=patient_care_plan.coordinator,
)
pc.make_active() # deactivates old cycle, sets this one as active
Create a new PatientCycle, call make_active(). The existing care_plan_action_trigger Celery task (runs every 5 minutes) picks it up and starts counting cycle_day from the new start_datetime. No new methods needed.
Multiple active PatientCarePlan per patient, care_plan_action_trigger task scheduling, BaseMessageHandler / NotificationHandler, AI Navigator via should_use_ai_navigator(), PatientCycle.make_active() pattern, PrEPDashboardAPI
3 event hooks (Visit.save(), Result.release_result(), EmrMedication post-save), LabKitAwareHandler checking AshLabKit status, data migration for 3 CarePlan templates, ~55 Action records, fix 3 .get() calls that assume single plan
Rollout Phases
Fix .get() single-plan assumptions in account/serializers.py, registration/dreg/form_actions.py, and lib/build_care_plan.py. Create data migration with 3 CarePlan templates (hvd-prep-intake, hvd-prep-labs, hvd-prep-rx) and ~55 Action records. Add event hooks in Visit.save(), Result.release_result(), and EmrMedication post-save.
Import flagged patients' lab results that currently exist as PDFs in Slack/Google Drive. Establish an ongoing import workflow for labs arriving via fax or Labcorp/Quest portals.
Prerequisite for labs trackEnable auto-enrollment for new patients on first visit. Backfill existing patients. Intake and prescription tracks launch immediately; labs track waits for Phase 2.
Intake + Rx can launch firstUpdate the Care Plan tab to show three tracks per patient. Update the PrEP Dashboard with track filtering and overdue sorting. Link status indicators to care plan detail views.
Tune notification timing. Expand labs trigger to full panel. Incorporate Curant pharmacy integration for actual fill/ship data. Add patient-facing care plan visibility.