Skip to main content
Synced from the repo — do not edit here

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:

DocPathField used
Templateclinicsv2/{clinic}/protocols/{id}activities (app/(main)/protocols/page.tsx:4117)
Per-client assignmentclinicsv2/{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-1923 builds a Map from habits first, then activities, 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

  1. Asymmetric write paths. A template edit writes activities; an assignment edit writes habits and clears activities. Any new code that touches protocol storage has to remember which doc it's holding.
  2. Stale docs in production. Per-client docs assigned before the update_protocol cleanup landed still carry both fields. They render correctly only because of the merge — drop the merge and you get double-rendering.
  3. Translation step is implicit. The frontend payload-builder in AssignProtocolModal.tsx remaps activities → habits before sending to assign_protocol. If that remapping ever breaks or is bypassed, the assignment doc would be empty and the client would see no activities.
  4. basishybrid only reads habits. If a template-to-assignment path ever forgot to remap and wrote activities into 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:

  1. Rewrite the template page (app/(main)/protocols/page.tsx:4117) to read/write habits.
  2. Run a one-time backfill that copies activities → habits on existing template docs (clinicsv2/{clinic}/protocols/{id}).
  3. Leave the defensive habits || activities reads in place for one release as a safety net.
  4. 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 workoutPreset shows 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.

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 recurringEvents array, not from protocol.habits.
  • Uses the recurrence rule's startsAt and until (RFC 5545 style, basiscore/lib/src/models/event/data/recurrence_rule.dart:147,153) for iteration bounds.
  • Copies (snapshots) workoutPreset / supplementPreset into each BasisEventV1 it 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 + type when habitId doesn'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:

  1. The OLD habit gets scheduledUntil = today (and rule.until = today). It stops generating expected events going forward, but it still exists, still shows on past days with its OLD preset.
  2. A NEW habit is created with a new id, the new preset, and scheduledFrom = 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.name
  • medicationPreset.medications[]
  • mealDatabaseIds set 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:00
  • workout.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:

  1. 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.habits and to recurringEvents.
  2. Strength habit existed (h-strength). Diff:

    • workoutPreset.exercises[].sets[].weight changed (material).
    • name changed (non-material).
    • Algorithm:
      • Patch the non-material part on h-strength (label → "Strength A"), keep scheduledFrom: 2026-05-06.
      • Set h-strength.scheduledUntil = 2026-05-23 and h-strength.rule.until = 2026-05-23T00:00:00Z. It will no longer materialize past May 23.
      • Create h-strength-v2 with the new workoutPreset (110lbs bench, 120lbs squat) and scheduledFrom: 2026-05-23, rule.startsAt: 2026-05-23T00:00:00Z. Same name "Strength A", same rule shape.
    • Both h-strength and h-strength-v2 now exist in protocol.habits and in recurringEvents.

Result after the May 23 edit:

DateWhat rendersSource
May 6 (Wed)"Strength A" planned/missed, 100/120h-strength materialized (now < scheduledUntil ∧ ≥ scheduledFrom)
May 13 (Wed)"Strength A" completed, 100/120 actually loggedreal event with habitId="h-strength"
May 22 (Wed)"Strength A" missed, 100/120h-strength materialized
May 23 (Sat)(no Mon/Wed/Fri match)
May 25 (Mon)"Strength A" planned, 110/120 + "Lunges" planned, 50h-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)

  1. assign_protocol (:885-1061):

    • For each habit in request.protocol.habits, stamp scheduledFrom = request.scheduledFrom or now on the habit and rule.startsAt matching. This is a no-op for existing assign flows because the protocol's startsAt is already today.
  2. update_protocol (:1531-1742) — new sub-logic when request.activities (or request.habits) is provided:

    • For each incoming activity:
      • If id matches 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, set scheduledFrom = now / rule.startsAt = now.
        • If only non-material changed → patch in place.
      • If id is new → add as new habit with scheduledFrom = now / rule.startsAt = now.
    • Propagate same retire-and-add semantics to recurringEvents array on user doc.
    • Important: never delete the old habit entry — past materialization needs to see it.
  3. Helper: _diff_is_material(old_habit, new_habit) → bool.

Web (hybrid/basisflow-web/)

  1. Materializer (app/(main)/clients/[id]/components/PlanTab.tsx:2934-3010):

    • Before generating an expected event for (habit, day), check:
      • habit.scheduledFrom (or habit.rule.startsAt) ≤ day, AND
      • habit.scheduledUntil (or habit.rule.until) absent OR > day.
    • Skip the day otherwise.
  2. Edit modals (AssignProtocolModal.tsx payload-builder, PlanTab.tsx's ActivityEditorModal save 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.
  3. Drawer rendering:

    • Where the activity-detail drawer reads workoutPreset, it should pick from the materialized event's snapshot (already does — payload.workoutPreset comes from the habit that was materialized for that day). No change needed.

Mobile (basishybrid)

No required code changes. The audit confirmed:

  • Recurrence engine already honors rule.startsAt and rule.until (service_recurring_events.dart:250-254).
  • recurringEvents array is the source of truth for materialization; protocol.habits is metadata only.
  • Presets are already snapshotted onto BasisEventV1 at 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 before rule.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.startDate when 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

  1. Ship backend update_protocol with diff/bump logic AND the per-habit-stamping for assign_protocol. Deploy both at once.
  2. Ship web materializer with the new per-habit window check.
  3. (Optional) Add the "material change — only applies going forward" disclaimer in the edit modal.
  4. 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 workoutPreset on 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.protocolId is 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 protocolId on the event (PlanTab.tsx QuickAddActivityModal handleSave payload). The reverse-mirror Cloud Function (functions_event_mirror.py: sync_clinic_event_to_user) propagates it to users/{uid}/events/{id}. Local-DB sync on mobile picks it up via the existing BasisEventV1.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.tsxselectedProtocol 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() snapshots recurringEvent.workoutPreset.exercises into a new BasisEventV1.exercises field.
  • 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:951rescheduleRecurringEvents() only rebuilds instances within a changeDate range. 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 updated recurringEvents array into the local DB but does not call rescheduleRecurringEvents(). 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 reads event.exercises directly 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:

  1. Diff the previous recurringEvents array against the new one.
  2. For each entry whose prescription fields changed (workoutPreset, supplementPreset, medicationPreset, mealDatabaseIds, mealGuidelines, rule, time, name), call serviceRecurringEvents.rescheduleRecurringEvents(habitId, changeDate: now()).
  3. rescheduleRecurringEvents already deletes-and-rebuilds instances from changeDate forward (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 id changed (handle id-bump separately when we implement §2's retire-and-add).
  • Don't reschedule past events that are already in completed / skipped state — 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.