Canonical source: docs/claude/plan-tab-fixes.md. This page is generated by docs/scripts/sync-handbook.mjs. Edit the source file in the repo; changes appear here on the next build.
Plan Tab — Fixes / Open Issues
Running list of bugs and rough edges in basisflow-web PlanTab.tsx (app/(main)/clients/[id]/components/PlanTab.tsx) and the protocol storage path it depends on. New items go to the bottom. Add a checkbox when filed, strike through when shipped.
1. Protocol habits vs activities field-name divergence
Status: Open First logged: 2026-05-22
What it is
Same conceptual list (the activities/habits inside a protocol) lives under different field names depending on which doc you're holding:
| Doc | Path | Field used |
|---|---|---|
| Template | clinicsv2/{clinic}/protocols/{id} | activities (app/(main)/protocols/page.tsx:4117) |
| Per-client assignment | clinicsv2/{clinic}/clinic_users/{uid}/protocols/{id} and users/{uid}/protocols/{id} | habits (basis-functions/functions/src/functions_clinic.py:1118) |
Where the duplication actually lives (historical)
functions_clinic.py:1624-1640 (update_protocol):
# Canonical storage is 'habits' (what Basis Hybrid reads). We used to
# write both 'habits' and 'activities' which caused the basisflow-web
# PlanTab to double-render every entry. Clear stale 'activities' too.
...
doc['habits'] = deduped
doc['activities'] = []
The backend used to write both fields on every assignment. That bug was patched on the update path, but only docs that have since been edited via update_protocol get the cleanup — older per-client docs still carry both habits and activities populated with overlapping entries.
Why the frontend "works anyway"
Every reader does a defensive merge:
PlanTab.tsx:1917-1923builds aMapfromhabitsfirst, thenactivities, deduped by id.AssignProtocolModal.tsx:437:const rawActivities = data.habits || data.activities || [].
The bandage keeps things rendering correctly. The mess is still there.
Why this is a real issue
- Asymmetric write paths. A template edit writes
activities; an assignment edit writeshabitsand clearsactivities. Any new code that touches protocol storage has to remember which doc it's holding. - Stale docs in production. Per-client docs assigned before the
update_protocolcleanup landed still carry both fields. They render correctly only because of the merge — drop the merge and you get double-rendering. - Translation step is implicit. The frontend payload-builder in
AssignProtocolModal.tsxremapsactivities → habitsbefore sending toassign_protocol. If that remapping ever breaks or is bypassed, the assignment doc would be empty and the client would see no activities. - basishybrid only reads
habits. If a template-to-assignment path ever forgot to remap and wroteactivitiesinto a per-client doc, the mobile app would silently see an empty protocol — no error, no warning, just missing data.
Suggested fix
Pick one field name and migrate. Recommended: habits everywhere because basishybrid already expects it and the per-client path already uses it — only templates and template UI need to move.
Plan:
- Rewrite the template page (
app/(main)/protocols/page.tsx:4117) to read/writehabits. - Run a one-time backfill that copies
activities → habitson existing template docs (clinicsv2/{clinic}/protocols/{id}). - Leave the defensive
habits || activitiesreads in place for one release as a safety net. - After verification, drop the fallback reads.
Risk is low — templates aren't read by mobile.
2. Edits to an assigned protocol retroactively change past instances
Status: Open First logged: 2026-05-23
What it is
When a coach edits an assigned protocol mid-way — adds a new habit, or changes set/rep/weight on an existing habit — the change is applied retroactively in the web week/month view:
- Past completed events keep their data (real Firestore docs with snapshots — safe).
- Past unfilled/"missed" days re-materialize from the current protocol state, so:
- newly-added habits show up as "missed" on every applicable day back to the protocol's
startDate, - changed
workoutPresetshows the new prescription on past missed days, as if it had always been prescribed, - the adherence chart and AI summary drop because of the phantom "missed" entries.
- newly-added habits show up as "missed" on every applicable day back to the protocol's
Why it happens
Web materializer (app/(main)/clients/[id]/components/PlanTab.tsx:2934-3010) only honors the whole-protocol window (protocol.startDate/endDate) and ignores per-habit scheduledFrom. Every habit currently in protocol.habits is generated for every applicable day in the visible range.
Backend (basis-functions/functions/src/functions_clinic.py:1689-1721, update_protocol) doesn't stamp a scheduledFrom / rule.startsAt on newly-added habits, so the materializer has no signal that "this habit started later than the protocol did."
basishybrid (basiscore/lib/src/services/service_recurring_events.dart:830-857):
- Materializes from the user doc's
recurringEventsarray, not fromprotocol.habits. - Uses the recurrence rule's
startsAtanduntil(RFC 5545 style,basiscore/lib/src/models/event/data/recurrence_rule.dart:147,153) for iteration bounds. - Copies (snapshots)
workoutPreset/supplementPresetinto eachBasisEventV1it creates — so past completed events on mobile show the preset that existed at completion time, NOT the current one. Safe on mobile by accident. - Has fallback matching by
name + typewhenhabitIddoesn't match (basishybrid/lib/view/routes/summary/route_summary.dart:1219-1258), which means bumping a habit-id doesn't strictly orphan past events — they're still findable.
The fix — per-habit scheduledFrom + bump-id on material edits
Two cooperating mechanisms:
(a) Per-habit scheduledFrom (+ rule.startsAt)
Every habit carries its own "applicable from" date. Defaults to protocol's scheduledFrom on initial assignment. New habits added mid-protocol get today. Web materializer skips days before that.
basishybrid already honors rule.startsAt for iteration — adding it is a no-op for the mobile app when the value equals the protocol's start, and "just works" when it's later.
(b) Bump-id on material edits
When the coach makes a material edit to an existing habit — change to workoutPreset.exercises, supplementPreset.dose, medicationPreset.medications — the old habit isn't mutated in place. Instead:
- The OLD habit gets
scheduledUntil = today(andrule.until = today). It stops generating expected events going forward, but it still exists, still shows on past days with its OLD preset. - A NEW habit is created with a new
id, the new preset, andscheduledFrom = today/rule.startsAt = today. It starts generating expected events from today forward with the new preset.
Non-material edits (rename, notes, time-of-day, frequency) patch in place — no bump.
What counts as "material" (initial list):
workoutPreset.exercises(any change to exercises[], sets[], reps, weight)supplementPreset.dose.amount,supplementPreset.dose.unit,supplementPreset.namemedicationPreset.medications[]mealDatabaseIdsset changes
Everything else (name text-only rename, time, frequency, duration, notes, description, energy-phase, selectedWeekDays) is patched in place.
Worked example — Lower Body Strength, assigned May 6
Initial state (May 6, 2026):
Coach assigns "Lower Body Strength" to Client X.
users/clientX/protocols/lb-strength_1714982400:
{
"protocolId": "lb-strength_1714982400",
"status": "active",
"scheduledFrom": "2026-05-06",
"habits": [
{
"id": "h-strength",
"name": "Strength Training",
"scheduledFrom": "2026-05-06", // ← stamp on every habit at assign-time
"rule": {
"frequency": "WEEKLY",
"byWeekDays": [{"day":1},{"day":3},{"day":5}],
"time": {"s": 64800}, // 18:00
"startsAt": "2026-05-06T00:00:00Z", // ← matches scheduledFrom
"duration": {"s": 3600}
},
"workoutPreset": {
"exercises": [
{ "exercise": {"id":"bench","name":"Bench Press"}, "sets":[{"reps":10,"weight":100,"unit":"lbs"}, ...3 sets] },
{ "exercise": {"id":"squat","name":"Squat"}, "sets":[{"reps":10,"weight":120,"unit":"lbs"}, ...3 sets] }
]
}
}
]
}
Same doc mirrored to clinicsv2/{clinic}/clinic_users/clientX/protocols/lb-strength_1714982400. recurringEvents array on users/clientX gets a corresponding entry for h-strength with the same rule.startsAt.
May 13 (Wed): Client logs the workout. Real event written to users/clientX/events/<eventId> with:
habitId: "h-strength"start: 2026-05-13T18:00:00workout.exercises: [bench@100lbs×3 sets logged, squat@120lbs×3 sets logged]status: completed
Reverse-mirror copies it to clinicsv2/{clinic}/clinic_users/clientX/events/<eventId>.
May 22 (Wed): Client misses the session. No event doc written. Week view materializes May 22 as a "missed" entry for h-strength with the current workoutPreset (still 100lbs bench, 120lbs squat) — that's correct, because that was the prescription on May 22.
May 23 (Sat) — Coach edits the protocol:
- Adds a new habit "Lunges 3x10 @ 50lbs" (Mon/Wed/Fri 18:00).
- Changes the existing strength habit's Bench Press from 100 → 110 lbs.
- Renames the strength habit's display label from "Strength Training" → "Strength A".
The frontend sends an updated activities array. The backend's update_protocol walks the diff:
-
Lunges is a NEW habit (id not in existing habits).
- Create
h-lunges(uuid) with:scheduledFrom: "2026-05-23"rule.startsAt: "2026-05-23T00:00:00Z"
- Append to
protocol.habitsand torecurringEvents.
- Create
-
Strength habit existed (
h-strength). Diff:workoutPreset.exercises[].sets[].weightchanged (material).namechanged (non-material).- Algorithm:
- Patch the non-material part on
h-strength(label → "Strength A"), keepscheduledFrom: 2026-05-06. - Set
h-strength.scheduledUntil = 2026-05-23andh-strength.rule.until = 2026-05-23T00:00:00Z. It will no longer materialize past May 23. - Create
h-strength-v2with the new workoutPreset (110lbs bench, 120lbs squat) andscheduledFrom: 2026-05-23,rule.startsAt: 2026-05-23T00:00:00Z. Same name "Strength A", same rule shape.
- Patch the non-material part on
- Both
h-strengthandh-strength-v2now exist inprotocol.habitsand inrecurringEvents.
Result after the May 23 edit:
| Date | What renders | Source |
|---|---|---|
| May 6 (Wed) | "Strength A" planned/missed, 100/120 | h-strength materialized (now < scheduledUntil ∧ ≥ scheduledFrom) |
| May 13 (Wed) | "Strength A" completed, 100/120 actually logged | real event with habitId="h-strength" |
| May 22 (Wed) | "Strength A" missed, 100/120 | h-strength materialized |
| May 23 (Sat) | (no Mon/Wed/Fri match) | — |
| May 25 (Mon) | "Strength A" planned, 110/120 + "Lunges" planned, 50 | h-strength-v2 + h-lunges materialized (≥ each habit's scheduledFrom) |
| ... |
Past completed event on May 13: unchanged, real data preserved. Past missed day on May 22: shows OLD prescription (correct). New habit Lunges: only appears from May 23 onward (correct). Adherence chart: doesn't drop retroactively.
What changes, file by file
Backend (basis-functions/functions/src/functions_clinic.py)
-
assign_protocol(:885-1061):- For each habit in
request.protocol.habits, stampscheduledFrom = request.scheduledFrom or nowon the habit andrule.startsAtmatching. This is a no-op for existing assign flows because the protocol's startsAt is already today.
- For each habit in
-
update_protocol(:1531-1742) — new sub-logic whenrequest.activities(orrequest.habits) is provided:- For each incoming activity:
- If
idmatches an existing habit, diff old vs new:- If material fields changed → mark old habit retired (
scheduledUntil = now,rule.until = now), create new habit with new uuid, copy non-material fields from old + material fields from new, setscheduledFrom = now/rule.startsAt = now. - If only non-material changed → patch in place.
- If material fields changed → mark old habit retired (
- If
idis new → add as new habit withscheduledFrom = now/rule.startsAt = now.
- If
- Propagate same retire-and-add semantics to
recurringEventsarray on user doc. - Important: never delete the old habit entry — past materialization needs to see it.
- For each incoming activity:
-
Helper:
_diff_is_material(old_habit, new_habit) → bool.
Web (hybrid/basisflow-web/)
-
Materializer (
app/(main)/clients/[id]/components/PlanTab.tsx:2934-3010):- Before generating an expected event for
(habit, day), check:habit.scheduledFrom(orhabit.rule.startsAt) ≤ day, ANDhabit.scheduledUntil(orhabit.rule.until) absent OR > day.
- Skip the day otherwise.
- Before generating an expected event for
-
Edit modals (
AssignProtocolModal.tsxpayload-builder,PlanTab.tsx'sActivityEditorModalsave path):- No change needed if backend owns the diff/bump logic. The frontend just sends the activities array.
- Exception: if the frontend wants to preview what will happen ("this edit will retire the current setup and create a new one starting today"), surface that in the modal. Recommend a small "Material change — will only apply going forward" disclaimer when a material field is edited.
-
Drawer rendering:
- Where the activity-detail drawer reads
workoutPreset, it should pick from the materialized event's snapshot (already does —payload.workoutPresetcomes from the habit that was materialized for that day). No change needed.
- Where the activity-detail drawer reads
Mobile (basishybrid)
No required code changes. The audit confirmed:
- Recurrence engine already honors
rule.startsAtandrule.until(service_recurring_events.dart:250-254). recurringEventsarray is the source of truth for materialization; protocol.habits is metadata only.- Presets are already snapshotted onto
BasisEventV1at materialization (:839-843). - Habit-id fallback by
name+type(route_summary.dart:1249) is already in place, so old completed events with old habit-ids still match the new habit for display purposes.
Strongly recommended verification step before shipping:
- Manually test on a TestFlight build with a protocol that has retired habits — confirm the mobile UI doesn't show the retired habit's expected days past
rule.until, and that the new habit's expected days don't start beforerule.startsAt. - Test adherence calculation (
ServiceRecurringEvents.determineCompletionStats()) — old completions referencing the retired habit-id should still count.
Firestore rules
No rule changes needed. The protocol doc write paths and recurringEvents writes are already permitted via existing rules. No new collections.
Migration of existing data
No required migration. Existing assignments without scheduledFrom / rule.startsAt keep working under the new materializer because:
- Web materializer falls back to
protocol.startDatewhen habit-level fields are absent (same as today). - basishybrid already runs this way.
When a coach makes the first material edit to an existing assignment, the backend will perform the retire-and-add operation and from that point forward both habit-id versions coexist. Past data unchanged.
Optional follow-up migration: a one-time script that stamps scheduledFrom: protocol.scheduledFrom on every habit in active assignments, just for tidiness. Not blocking.
Rollout
- Ship backend
update_protocolwith diff/bump logic AND the per-habit-stamping forassign_protocol. Deploy both at once. - Ship web materializer with the new per-habit window check.
- (Optional) Add the "material change — only applies going forward" disclaimer in the edit modal.
- Sanity-check on mobile that retired habits still render correctly for past completed days and disappear from future expected days.
Notes on what NOT to do
- Do not bump habit-id on every edit — only material edits. Renaming a habit should not invalidate the past.
- Do not mutate
workoutPreseton past materialized events — they don't exist as Firestore docs to begin with; they're rebuilt each render. The fix is to make the materializer pick the right habit version for each day. - Do not delete the retired habit from
protocol.habits— the materializer needs it to render the past. - Do not retroactively bump habit-ids on the old habit — it must keep its existing id so old completed events still reference it correctly.
3. basishybrid protocol-detail view drops one-off events tagged with a protocolId
Status: FIXED (verified 2026-07-26, #629 audit pass) — filterHabitsEvents carries the optional protocolId param (service_recurring_events.dart:1362-1374) and widget_protocol_detailed.dart passes effectiveProtocolId through (:113-117). Kept for reference.
First logged: 2026-05-23
What it is
When staff use the basisflow-web "+" quick-add (or the existing strength-training session-create button) to drop a non-recurring event onto a client's day and tag it with a protocolId, the event:
- Renders correctly on the basishybrid home/today screen for that day. ✓
- Renders correctly on the basisflow-web week/month view for that day. ✓
- Does NOT render under that protocol's detail view on basishybrid. ✗
So a client opening "Plan tab → Lower Body Strength" on mobile only sees the recurring habits, never the one-offs the coach attached to it. Confusing — the coach intended the activity to be part of that protocol.
Where the gate lives
basiscore/lib/src/services/service_recurring_events.dart:1344-1352:
static Iterable<BasisEventV1> filterHabitsEvents(
final List<BasisRecurringEventV1> habits,
final Iterable<BasisEventV1> events,
) {
if (habits.isEmpty) return const [];
final availableIds = habits.map((e) => e.id).toSet();
return events
.where((final e) => availableIds.contains(e.recurrenceRule?.recurringEventId));
}
A one-off event has no recurrenceRule.recurringEventId, so it's dropped — even when it carries protocolId == <this protocol's id>.
Background: it's already in the query result
determineEvents (basiscore/.../service_recurring_events.dart:1378-1402) queries the local DB for events with type IN <habit types union>. Since the protocol's habits include the same type the one-off was written with (the QuickAdd UI is constrained to ACTIVITIES entries that match basishybrid BasisEventType values), the one-off is in the result. filterHabitsEvents is the only thing dropping it.
Compatibility checks
BasisEventV1.protocolIdis defined and serialized:basiscore/lib/src/models/event/event.dart:353, :437, :497, :549, :603, :658. End-to-end coverage already exists.- Web QuickAdd flow already writes
protocolIdon the event (PlanTab.tsxQuickAddActivityModalhandleSavepayload). The reverse-mirror Cloud Function (functions_event_mirror.py: sync_clinic_event_to_user) propagates it tousers/{uid}/events/{id}. Local-DB sync on mobile picks it up via the existingBasisEventV1.fromData. - No firestore.rules / no schema migration needed.
Fix
Two small Dart changes.
(a) basiscore/lib/src/services/service_recurring_events.dart — extend the filter to also pass events whose protocolId matches:
static Iterable<BasisEventV1> filterHabitsEvents(
final List<BasisRecurringEventV1> habits,
final Iterable<BasisEventV1> events, {
final String? protocolId,
}) {
final habitIds = habits.map((e) => e.id).toSet();
if (habitIds.isEmpty && (protocolId == null || protocolId.isEmpty)) return const [];
return events.where((final e) {
if (habitIds.contains(e.recurrenceRule?.recurringEventId)) return true;
if (protocolId != null && protocolId.isNotEmpty && e.protocolId == protocolId) return true;
return false;
});
}
Backwards-compatible — existing call sites that don't pass protocolId behave exactly as before.
(b) basishybrid/lib/view/routes/habits/widget_protocol_detailed.dart:104-107 — pass the protocol's id through:
final events = ServiceRecurringEvents.filterHabitsEvents(
habitsToUse,
await recurring.determineEvents(uid: user.uid, habits: habitsToUse),
protocolId: protocol.id,
).toList(growable: false);
Known limitation (acceptable for v1)
determineEvents filters its local-DB query by type IN <union of habit types in protocol>. If a coach associates a one-off with a protocol and picks an activity whose type doesn't appear in any of that protocol's habits (e.g., a habitWalk one-off tagged to a strength-only protocol), the event is dropped by determineEvents before it ever reaches filterHabitsEvents. That edge case is rare in practice — coaches who want ongoing walks in a protocol add a walk habit, which immediately includes habitWalk in the type union. If we ever need to make this airtight, we can add a protocolId filter to LocalDBEventQueryParams (Rust FFI side too) and do a second-pass query.
Web symmetry note
The basisflow-web protocol-detail UI (app/(main)/clients/[id]/components/PlanTab.tsx — selectedProtocol view) currently renders the configured habits list (templates), not protocol-linked event instances. The home/week view DOES show the one-offs in the right day cell with a "From Protocol" chip (PlanTab.tsx:5953 area renders activity.protocolTitle). So web parity for "the same protocol-tagged one-off shows in the protocol detail view" is a separate, similar task — file it later if needed.
4. basishybrid renders stale workoutPreset on expected instances after a web edit
Status: Open — DEFERRED from the pre-IPA pass (2026-07-26, #629): the recurrence engine already re-materializes from the current mirror on every app launch (verified in logs: delete-old-instances + reschedule per recurring event at startup), so staleness is bounded to a single running session. A mid-session fix must hook the remote user-doc stream, which ECHOES the app's own writes — a naive preset-diff there risks reschedule storms. Per the recommendation below, bundle into the habit-versioning fix (item 2) instead of building standalone. First logged: 2026-05-26
What it is
Staff edits a protocol's strength habit on basisflow-web (adds an exercise, changes set/rep/weight, adds a videoUrl). On the basisflow-web protocol-detail view the change appears correctly. On basishybrid, however, opening an "expected" (planned, not-yet-completed) instance for that habit on any day still shows the OLD exercises — the new ones don't appear.
Distinct from §2: §2 was about the web materializer applying current protocol state to past dates. §4 is the mobile-side mirror image: persisted snapshots that never get refreshed.
Where it lives
basiscore/lib/src/services/service_recurring_events.dart:843-855—_createEventFromRecurringEvent()snapshotsrecurringEvent.workoutPreset.exercisesinto a newBasisEventV1.exercisesfield.basiscore/lib/src/services/service_recurring_events.dart:1017— that materialized event is written to the local DB, persisting the snapshot.basiscore/lib/src/services/service_recurring_events.dart:951—rescheduleRecurringEvents()only rebuilds instances within achangeDaterange. Pre-existing materialized events outside that range never get refreshed.basishybrid/lib/services/service_firebase_sync.dart:256-270— the user-doc sync stream propagates the updatedrecurringEventsarray into the local DB but does not callrescheduleRecurringEvents(). So new prescription data lands in the recurringEvent record, but already-materialized event records keep their stale snapshot.basishybrid/lib/view/routes/data/widget_event.dart:52— instance render readsevent.exercisesdirectly from the persisted record. That's the stale snapshot.
Backend is doing the right thing
functions_clinic.py:1655-1721 (update_protocol) writes the new workoutPreset into both:
- the protocol doc, and
- the user-doc's
recurringEvents[i]entry (and the clinic mirror).
So the source of truth is updated. Mobile just doesn't re-read it for instances it had already materialized.
Web-side: not affected
Web's materializer (PlanTab.tsx:2934-3010) recomputes the expected list every render straight from the protocol's habits array. No persisted snapshot, no cache to invalidate. That's why the protocol detail view on web is correct.
Suggested fix
In service_firebase_sync.dart (around :256-270), when the user-doc snapshot fires:
- Diff the previous
recurringEventsarray against the new one. - For each entry whose prescription fields changed (
workoutPreset,supplementPreset,medicationPreset,mealDatabaseIds,mealGuidelines,rule,time,name), callserviceRecurringEvents.rescheduleRecurringEvents(habitId, changeDate: now()). rescheduleRecurringEventsalready deletes-and-rebuilds instances fromchangeDateforward (service_recurring_events.dart:951+), so this is future-only — completed past events keep their actual data.
Two safety constraints:
- Don't reschedule when only
idchanged (handle id-bump separately when we implement §2's retire-and-add). - Don't reschedule past events that are already in
completed/skippedstate — they're real workout records, not expected placeholders.
Intersection with §2
§2's bump-id approach naturally avoids this bug for material edits: when a habit's prescription changes, §2 retires the old habit (scheduledUntil = now) and adds a new habit with a fresh id starting today. Mobile's recurrence engine already honors rule.until and rule.startsAt (audit confirmed), so:
- Old habit stops generating new expected events past
scheduledUntil— stale snapshots stick around for past dates, which is correct (past data shouldn't mutate). - New habit generates fresh expected events from today onward with the new prescription.
So if we ship §2 first, §4 becomes mostly moot for material edits. The remaining cases — non-material edits that don't bump the id (rename, time-of-day change, frequency change) — would still benefit from the §4 fix, but those rarely change what the patient sees per-instance.
Recommendation
Skip standalone §4 work. Bundle the relevant subset into §2's implementation: when §2's diff logic detects a non-material edit that updates a habit in place (e.g., time change), also call rescheduleRecurringEvents for that habit. For material edits, the bump-id approach handles it. This keeps the basishybrid change small (one diff + one method call in the sync service) and avoids duplicating logic.