Documentation updates with IDAA ideas.

This commit is contained in:
Scott Idem
2026-05-18 08:56:53 -04:00
parent ab9e54d768
commit 3ed1a2a6c4
2 changed files with 538 additions and 16 deletions

View File

@@ -0,0 +1,515 @@
# Aether UI/UX — Future Ideas
> Collection of concrete UX improvements for the Aether frontend. Each entry includes
> the rationale, current behavior, proposed change, and implementation notes.
> **Date:** 2026-05-17
---
## IDAA Recovery Meetings
### 1. Guided empty state with active filters
**Current behavior:** When filters return 0 results, the page shows:
"No recovery meetings found matching your criteria."
The member has no indication whether this is a bug, genuinely no data, or just
overly narrow filters.
**Proposed change:** When filters are active AND the result count is 0, show a
helpful prompt instead of the bare message:
```
No meetings found for these filters.
Try broadening your search or [Clear all filters →]
```
**Implementation notes:**
- Add a `has_active_filters` derived in `+page.svelte` that checks whether any of
`qry__physical`, `qry__virtual`, `qry__type`, or `qry__fulltext_str` is set.
- In the template's `{:else}` block (line ~443), branch on `has_active_filters`:
- `true` → show the guided message + "Clear Filters" button
- `false` → show the existing escape-hatch flow (timed "Refresh Meeting Cache" button
after 8 seconds, since zero unfiltered results always indicates a problem)
- The "Clear Filters" button resets all four filter fields to `null`/`''` and bumps
`search_version` to trigger a fresh unfiltered search.
- Distinct from the `error` state — this is a successful search (`qry__status === 'done'`)
with an empty result set.
---
### 2. Quick-filter chips below the search bar
**Current behavior:** Members toggle filters via small checkboxes (Virtual, In-person)
and radio buttons (All, IDAA, Caduceus, Family Recovery). These require precise
mouse/tap targeting and scanning several lines of filter UI to discover and use.
**Proposed change:** Add a row of preset chip buttons directly below the search input:
```
[🖥 Virtual] [🏠 In-Person] [🩺 IDAA] [Caduceus] [Family Recovery] [All Types]
```
- Each chip toggles the corresponding filter (`qry__virtual`, `qry__physical`, `qry__type`)
and triggers an immediate search.
- Selected chips get a filled/pressed style; unselected chips are outlined.
- "All Types" is the default selected state (no type filter). Clicking another type
chip deselects "All Types" (radio behavior for the type dimension). Virtual and
In-person are independent toggles (checkbox behavior — can select both).
- The existing checkboxes/radio buttons remain as the underlying state storage
(`$idaa_loc.recovery_meetings.*`). The chips are a convenience layer — they write
to the same store fields and call `handle_search_trigger()`.
**Implementation notes:**
- Place in `ae_idaa_comp__event_obj_qry.svelte` between the search input row and the
current filter rows.
- Optionally hide the existing checkbox/radio filter rows when the chips are present
(or keep both — the checkboxes serve as accessible form controls; the chips are
the primary visual interaction).
- On mobile, chips wrap to a second row naturally with `flex-wrap`.
---
### 3. Language: "Searching..." vs "Loading..."
**Current behavior:** The loading state always shows the same message:
```
🔄 Searching...
```
This appears on initial page load (when the user hasn't typed anything) and after
the user clicks Search or toggles a filter. The word "Searching" implies the user
initiated a search, which is misleading on initial page load — it's a cold cache
load, not an active search.
**Proposed change:** Distinguish the two loading contexts:
| Context | Message |
|---------|---------|
| Initial page load (no filters, no search text) | "Loading meetings..." |
| User clicked Search or toggled a filter | "Searching..." (keep current) |
**Implementation notes:**
- In `+page.svelte` template around line 422, check whether `qry__fulltext_str` is
empty AND no filter checkboxes/radios are active. If so, show "Loading meetings...";
otherwise show "Searching...".
- This is purely a label change — no logic changes needed. The condition can be the
same `has_active_filters` derived from item #1.
- Also update the list component's standalone loading state in
`ae_idaa_comp__event_obj_li.svelte` line 556-558 to use the same distinction.
---
### 4. Filter row collapsing on mobile
**Current behavior:** The query bar has three filter rows (Location checkboxes,
Type radios, Max/Sort selects) plus the search input row and the action button row.
Combined, this takes roughly 200px of vertical space. On mobile — especially inside
the Novi iframe on a phone — meeting cards are pushed below the fold.
**Proposed change:** On viewports below `md` (768px), collapse the Location and Type
filter rows behind a "Filters ▾" toggle. The Max Results and Sort selects stay visible
since they're used frequently. The action buttons (Show Hidden, Create Meeting, Export)
move inside the collapsed panel or stay visible based on available width.
```
[Search input............................] [Search]
[Filters ▾] [Max: 150 ▾] [Sort: Last Updated ▾]
```
Clicking "Filters ▾" expands the panel with Location checkboxes and Type radios.
**Implementation notes:**
- Use a `$state` boolean `show_filters` (session-only, resets on page load).
- Wrap the filter rows in a `{#if show_filters}` block.
- Persist in `$idaa_sess.recovery_meetings.show_filters_expanded` if you want the
state to survive navigation within the module (same tab session).
- The Tailwind `md:` breakpoint works for the collapse trigger: `class:hidden={!show_filters}`
combined with `class:md:block` to always show on desktop.
- Test inside the Novi iframe — Bootstrap v3 may add its own `hidden` behavior on
`md` breakpoints that conflicts with Tailwind's.
---
### 5. Human-readable schedule line on cards
**Current behavior:** The meeting card displays weekdays as a flat, dense span list:
```
Sunday Monday Wednesday Friday
```
The timezone is shown separately as `(America/Chicago)`, and the start time is in
a compact `7:00 PM` format. These three pieces of information are visually separated
and require the member to mentally assemble the schedule.
**Proposed change:** Render a computed one-liner that combines them:
```
🕐 Mondays, Wednesdays, Fridays at 7:00 PM CT
```
- Weekday names are built from the `weekday_*` booleans on the event object.
- "Mondays, Wednesdays" uses the range-joining convention (comma-separated, "and"
before the last item for two days; "Mondays through Fridays" for consecutive spans
of 3+ days).
- Timezone abbreviation is extracted from `timezone` (e.g., `America/Chicago``CT`,
`America/New_York``ET`). A small lookup table handles the common ones; fall back
to the raw timezone string for unknown values.
- If `timezone` is null/missing, fall back to the current flat display — don't
silently drop information.
- Today's meetings could optionally get a subtle "Today" badge or highlight (extra
polish, not required for the initial version).
**Implementation notes:**
- Add a `$derived` in `ae_idaa_comp__event_obj_li.svelte` that computes the schedule
string from the event object's `weekday_*` fields, `recurring_start_time`, and
`timezone`.
- Helper function in `ae_util` for the weekday list → natural language string
(e.g., `['Monday', 'Wednesday', 'Friday']``"Mondays, Wednesdays, and Fridays"`).
- Helper function or small lookup for timezone → abbreviation.
- Fall back to the current flat display when `timezone` is missing to avoid losing
information.
---
### 6. Show result count during search, not just after
**Current behavior:** The result count badge ("Results: 25") only appears inside the
list wrapper component (`ae_idaa_comp__event_obj_li.svelte` line 98-108) when the
visible result list is non-empty. During loading, the user sees only a spinner with
no indication of how many meetings exist or what the search is operating on.
**Proposed change:** Show a result count line at the page level (in `+page.svelte`)
that is always visible once the first search completes:
```
25 of 140 meetings ← after search completes, with result count + total
Searching 140 meetings... ← during initial load (cold cache, no prior result)
0 results for these filters ← empty but filters are active (ties into item #1)
```
**Implementation notes:**
- Lift the count display from the list component to `+page.svelte`, placed between
the query bar (`Comp__event_obj_qry`) and the list wrapper.
- The total count is available from the IDB fast path: after the initial unfiltered
search populates `db_events.event`, the total is `db_events.event.count()` (or
the count of records matching `account_id`).
- The visible count is `event_id_li.length` after search completes.
- Store the last known total in a `$state` variable so it persists across searches
(the total changes infrequently). Refresh the total on the first search after
page load.
- Format: `{visible} of {total} meetings` when filters/search are active;
`{visible} meetings` when browsing all (no active filters).
- During loading with no prior results: show "Loading meetings..." (from item #3)
rather than a count.
---
### 7. "Live Now" and "Starting Soon" indicators
**Current behavior:** Meetings are shown in a static list. To find one happening
now, a member must scan the "When" line of multiple cards and compare the time
to their own clock.
**Proposed change:** Add a high-visibility badge or pulse indicator for meetings
that are currently in progress or starting in the next 15 minutes.
- "LIVE NOW" (Green pulse badge) → if `current_time` is within `[start, start + 1 hour]`.
- "STARTING SOON" (Yellow badge) → if `current_time` is within `[start - 15 min, start]`.
- On the card, move the "Join Zoom" or "Join Jitsi" button to the very top or
make it significantly larger when the meeting is live.
**Implementation notes:**
- Add a `$derived` state `is_live` and `is_starting_soon` to the card component.
- Requires calculating "current time in meeting's timezone" using `Temporal` or
a date helper.
- Ensure the pulse animation is subtle and respects `prefers-reduced-motion`.
---
### 8. Local Timezone Conversion
**Current behavior:** Meetings show their native timezone (e.g., "7:00 PM America/Chicago").
The "Your TZ" line is currently a placeholder and doesn't perform conversion.
**Proposed change:** Automatically detect the member's browser timezone and
show the converted time if it differs from the meeting's native timezone.
```
🕐 7:00 PM CT (8:00 PM ET your time)
```
**Implementation notes:**
- Use `Intl.DateTimeFormat().resolvedOptions().timeZone` to get the user's TZ.
- If `user_tz !== meeting_tz`, perform the conversion.
- If the conversion results in a different day (e.g., late night ET vs early morning Europe),
prefix with "Tomorrow at..." or "Yesterday at...".
---
### 9. Favorites / "My Meetings"
**Current behavior:** Members scan the full list every time they want to find
their regular weekly meeting.
**Proposed change:** Add a "Star" icon to every meeting card.
- Starring a meeting adds it to a `favorites` list stored in `$idaa_loc`.
- Favorited meetings are pinned to the top of the list by default, regardless
of other sort orders.
- Add a "Favorites" filter toggle in the query bar to show *only* starred meetings.
**Implementation notes:**
- Store as an array of `event_id` strings in `$idaa_loc.recovery_meetings.favorites`.
- Update the `visible_event_obj_li` derived in `+page.svelte` to prioritize
these IDs in the sort logic.
---
### 10. "Add to Calendar" (iCal / Google)
**Current behavior:** Members must manually create calendar events if they
want reminders for recurring meetings.
**Proposed change:** Add an "Add to Calendar" dropdown button on the meeting
detail page (and optionally the card).
- Generates a `.ics` file or a Google Calendar URL with the recurring rule
(e.g., "Every Wednesday at 7pm").
- Includes the meeting name, description, and the Zoom/Jitsi link in the location field.
**Implementation notes:**
- Use a helper to generate RFC 5545 `RRULE` strings from the `weekday_*` and
`recurring_pattern` fields.
- Include the `attend_url` in the calendar event description for one-tap join
from phone lock screens.
---
### 11. Geographic Search for In-Person Meetings
**Current behavior:** The only location filter is a binary "Physical" checkbox.
Members must use fulltext search (e.g., "Chicago") to find local meetings.
**Proposed change:** Add a "City/State" search input or a map view.
- When `Physical` is checked, show a "Near [City, State]" input.
- Map view (optional): A toggle to switch from "List" to "Map" view, plotting
meetings on a map using their `location_address_json` coordinates.
**Implementation notes:**
- The `event` table has `location_address_json` which often contains city/state.
- Simple implementation: a city-picker dropdown populated from the distinct
`location_address_json->>'$.city'` values in the current result set.
---
### 12. Prominent "Join" button for virtual meetings
**Current behavior:** On each meeting card, the Zoom or Jitsi join link is rendered
as a small `btn-sm` inside the content area, visually equivalent to other label/value
rows. The "Meeting Details" button at the top of the card is rendered *larger* than the
join link — meaning the primary action for a member who wants to attend a meeting right
now is visually subordinate to a navigation link.
The copy-to-clipboard button for the join link is gated behind `$ae_loc.manager_access`,
so regular members have no easy way to share the link with a sponsee.
**Proposed change:** For virtual meetings, elevate the join button to a full-width
prominent CTA inside the card header area, directly below the meeting name and badges:
```
┌──────────────────────────────────────────────────┐
│ 📅 Monday Night IDAA Discussion 🖥 Virtual │
│ │
│ [ 🎥 Join Zoom Meeting ] ← full-width │
│ [ 📋 Meeting Details ] │
└──────────────────────────────────────────────────┘
```
- The Join button uses `preset-filled` (solid) styling; Meeting Details uses
`preset-outlined` (hollow). This makes the action hierarchy visually clear.
- On mobile especially, a full-width join button is much easier to tap than a
small inline link buried inside label rows.
- Replace manager-only clipboard with a Web Share API button for all members on
virtual meetings: `navigator.share({ title, url })` on mobile triggers the native
OS share sheet. Fall back to clipboard copy on desktop (where `navigator.share`
is often unavailable). This lets members easily send a meeting link to a sponsee.
- Passcode, if present, moves to the Meeting Details page — exposing it in the
list view is unnecessary and clutters the card.
**Implementation notes:**
- In `ae_idaa_comp__event_obj_li.svelte`, move the Zoom/Jitsi attend block from
the `event__content` section up into the `ae_options` div (line ~200), rendered
only when `idaa_event_obj?.virtual` is true and an attend URL exists.
- Keep the existing small label/link in `event__content` as a fallback for when
the prominent button is not shown (non-virtual meetings may still have a URL).
- Web Share: `{#if navigator?.share}` guard; wrap in a try/catch (user cancels
the share sheet throws `AbortError`).
- The Live Now / Starting Soon badges from item #7, when implemented, should also
interact with this button — e.g., a pulsing green border when the meeting is live.
---
### 13. "Today's Meetings" section at the top of the list
**Current behavior:** The meeting list shows all results sorted by the selected sort
order. To find a meeting happening today, a member must scan every card's "When" line
and mentally compare it to the current day and time. There is no at-a-glance view
of what's available right now or later today.
This is distinct from the "Live Now" badge in item #7 (which marks individual cards
after they're already displayed in a long list). This is a dedicated section pinned
above the main results.
**Proposed change:** Add a collapsible "Today" section at the very top of the results
list that shows only meetings scheduled on the current day of the week, sorted by
start time:
```
▼ Today — Sunday, May 17 3 meetings
┌─────────────────────────────────────────────────┐
│ Sunday Serenity Discussion 7:00 AM ET 🔴Live │
│ IDAA Sunday Big Book 2:00 PM CT │
│ Sunday Night IDAA 8:00 PM ET │
└─────────────────────────────────────────────────┘
All Meetings (140)
...
```
- The section is collapsed by default if it's empty (no meetings today).
- Meetings in the "Today" section also appear in the main list below — this is a
quick-access shortcut, not a filter.
- Past meetings (start time has already passed today) are dimmed but still shown;
a meeting may still be in progress.
**Implementation notes:**
- Compute current day-of-week in the browser: `new Date().getDay()` → 0=Sunday, 6=Saturday.
Map to the `weekday_*` boolean fields on the event object (e.g., day 0 → `weekday_sunday`).
- Filter `visible_event_obj_li` (already computed in the list wrapper) for items where
the matching `weekday_*` field is truthy. Sort by `recurring_start_time`.
- Store collapse state in `$idaa_sess.recovery_meetings.today_section_expanded` (session
only; default true so it's visible on first load).
- Renders correctly in the Novi iframe since it's just a filtered sub-list of existing
data — no additional API calls needed.
- If item #7 (Live Now) is implemented, the "Today" section naturally becomes the host
for the live/starting-soon badges, since that's where members will look first.
---
### 14. Data freshness indicator *(low priority — deprioritized)*
**Note:** Meeting records change infrequently — once established, a meeting's schedule,
type, and contact info are typically stable for months or years. The occasional update is
usually minor wording. Surfacing a freshness indicator for data this static would add
visual noise with very little member benefit. The existing error state (item #4 in the
bug fix, distinct "Unable to load meetings") and the escape-hatch cache-reset button
already handle the reliability-concern case. This idea is recorded for completeness but
is not recommended for implementation.
---
### 15. "Confirmed meetings only" default filter
**Current behavior:** All meetings are shown by default, including those with
`status === 'unknown'` (not yet confirmed by IDAA Central Office). The "Not Confirmed
by IDAA" warning badge on those cards is alarming-looking but does nothing to prevent
unverified meetings from dominating the list.
There is currently no filter to hide unconfirmed meetings. Members have no choice but
to see them all.
**Business rationale:** IDAA staff want meeting chairs to submit their meeting info for
verification. Defaulting to "confirmed only" creates a natural incentive: unconfirmed
meetings disappear from the default member view, which encourages chairs to contact
IDAA staff and get their meeting verified. It also gives members a cleaner, higher-
confidence list by default — they're not seeing meetings that may be outdated or
inactive.
**Proposed change:** Add a `qry__confirmed` filter field with three states:
| Value | Behavior |
|-------|----------|
| `'confirmed_only'` (default) | Hide meetings where `status === 'unknown'` |
| `'all'` | Show all meetings, confirmed and unconfirmed |
| `'unconfirmed_only'` | Show only unconfirmed (admin/staff use) |
The filter UI shows a simple toggle in the query bar:
```
[✓ Confirmed Only] ← default, shown as active chip or checkbox
```
When the member switches to "All", the unconfirmed meetings appear with their warning
badge (see item #15 for making that badge useful on mobile).
For trusted/admin users, the default should remain `'all'` so staff can see the full
picture without having to change a setting.
**Implementation notes:**
- Add `qry__confirmed: 'confirmed_only' | 'all' | 'unconfirmed_only'` to
`$idaa_loc.recovery_meetings` defaults, defaulting to `'confirmed_only'`.
Trusted users default to `'all'`.
- Apply the filter in both the IDB fast path (`db_events.event.filter()`) and the
API revalidation secondary filter in `handle_search_refresh`. IDB: check
`ev.status !== 'unknown'` when `qry__confirmed === 'confirmed_only'`. API: same
post-fetch client-side filter.
- Pass `qry__confirmed` to `events_func.search__event` if the API supports a
`status` filter param; otherwise handle it client-side only.
- The `no_results_no_filters` derived (used for the escape-hatch button) should NOT
treat `qry__confirmed === 'confirmed_only'` as an active filter — it's the default
state, not a narrowing choice the member made. Only count it as a filter if the
member explicitly switched it to `'all'` or `'unconfirmed_only'`.
- Add a count badge to the toggle: "Confirmed Only (132 of 140)" so members can see
how many unconfirmed meetings exist without having to switch the filter.
---
### 16. "Not Confirmed" status — inline explanation on mobile
**Current behavior:** Meetings with `status === 'unknown'` show a warning badge:
`⚠ Not Confirmed by IDAA ⚠`. The badge has a `title` attribute with a full explanation
(~2 sentences). Title tooltips are invisible on mobile — tapping the badge does nothing.
Members on phones see a alarming-looking warning with no explanation of what it means
or what they should do.
**Proposed change:** Make the badge tappable. On tap (or hover on desktop), show an
inline explanation panel directly below the badge:
```
⚠ Not Confirmed by IDAA [?]
↓ (on tap)
This meeting has not been confirmed by IDAA Central Office.
Please reach out to the chair for current information.
If this meeting is active, email info@idaa.org to confirm it.
[✕ Close]
```
**Implementation notes:**
- Add a `$state show_unconfirmed_info = false` per card (scoped to the `{#each}` block).
- Replace the `title` attribute with an `onclick` toggle that sets `show_unconfirmed_info`.
- The explanation renders in a `{#if show_unconfirmed_info}` block directly below the
badge row — a simple div with a rounded border and the existing tooltip text.
- The `mailto:info@idaa.org` link in the explanation is already in the tooltip text;
making it a real clickable link here rather than plain text in a tooltip is a direct
improvement for mobile members who want to report a confirmed meeting.
- This pattern also applies to other `title`-only tooltips on the page if they appear.
- Note: with item #15 defaulting to "confirmed only", most members will never encounter
this badge unless they switch to the "All" view. The inline explanation is still worth
implementing for that audience, but the default filter reduces how often it's seen.
---
## Notes
- The fulltext search (`qry__fulltext_str`) searches against the `default_qry_str`
field, which is a server-side composite that already includes contact info, day-of-week
text, meeting type, location, and other metadata. The placeholder text in the search
input is accurate — it genuinely searches contacts and schedule information despite
those fields being stored in separate columns.
- All changes must render correctly inside the Novi iframe context (Bootstrap v3.4.1
CSS conflicts — see `CLIENT__IDAA_and_customized_mods.md` for known issues).
- Mobile testing should cover Android Chrome specifically — the original "no meetings
found" bug disproportionately affected mobile users with intermittent connections.

View File

@@ -115,33 +115,40 @@ reactivity — only effects that actually read a changed field re-run.
---
### [Stores] IDB Content Version System (post June 10)
Scaffold added to `store_versions.ts` (`IDB_CONTENT_VERSIONS` constant) — values defined but
**not yet wired**. The mechanism mirrors `AE_LOC_VERSION` but targets Dexie table contents
rather than localStorage keys.
### [Stores] IDB Content Version System
Scaffolded in `store_versions.ts` (`IDB_CONTENT_VERSIONS` constant + `check_and_clear_idb_table()`
helper) and `core__idb_dexie.ts` (`check_and_clear_idb_tables()` batch helper). Mirrors
`AE_LOC_VERSION` but targets Dexie table contents rather than localStorage keys.
**Why:** `db_save_ae_obj_li__ae_obj` uses a `properties_to_save` whitelist. When that whitelist
changes (e.g. adding/removing a stored field), existing cached IDB records are stale but never
automatically cleared. Users see the old shape until a record is individually refreshed.
**Currently active:** `journals.journal_entry` (db_journals.ts), `events.event` (IDAA layout).
All other tables are defined but not yet wired.
**How it will work:**
- Each `db_*.ts` calls a helper (`core__idb_dexie.ts`) on open that checks a `_meta` IDB table
- If stored version ≠ `IDB_CONTENT_VERSIONS[module][table]`, clear the table + update `_meta`
- SWR repopulates from API on next access (same as any cold-start)
**Real-world impact:** Stale IDB records from a `properties_to_save` change were the root cause
of the IDAA Recovery Meetings "no meetings found" bug — a ~1-year unresolved issue (20252026).
Fixed 2026-05-16 by wiring `events.event` into the IDAA layout and bumping its version to 2.
See `BOOTSTRAP__AI_Agent_Quickstart.md` mistake #13 for the full postmortem.
**How it works:**
- `check_and_clear_idb_table(db_table, 'module', 'table')` reads a localStorage key with the
expected version from `IDB_CONTENT_VERSIONS`
- On mismatch (or missing key), the Dexie table is cleared and the key is updated
- SWR repopulates from API on next access — no explicit reload needed
- Cost on version match: one `localStorage.getItem()` — effectively free
- Bump a table's version in `IDB_CONTENT_VERSIONS` when `properties_to_save` changes shape
**IDAA consideration:**
IDAA tables are already cleared by `indexedDB.deleteDatabase()` on sign-out/auth failure in
`(idaa)/+layout.svelte`. The content version check is a *complementary* deploy-time reset, not
a replacement. When wiring IDAA tables, ensure: (a) the check only runs on IDB open, not
mid-session; (b) the `_meta` table is included in the `deleteDatabase()` wipe scope.
a replacement.
**Tasks:**
- [x] Write `check_and_clear_idb_tables()` helper in `core__idb_dexie.ts` (2026-05-14)
- [x] Wire helper into `db_journals.ts` (pilot — `journal_entry: 2` clears stale content_md_html on first load) (2026-05-14)
- [ ] Roll out to `db_events.ts`, `db_core.ts`
- [x] Wire helper into `db_journals.ts` (pilot — `journal_entry: 2` cleared stale content_md_html) (2026-05-14)
- [x] Wire `events.event` into IDAA layout `(idaa)/+layout.svelte` + bump version to 2 (2026-05-16)
- [ ] Roll out to `db_events.ts` (module-wide: session, presenter, badge, device, location, file)
- [ ] Roll out to `db_core.ts` (site_domain, person, user)
- [ ] Roll out to IDAA modules (`db_posts.ts`, `db_archives.ts`) — verify auth-wipe interaction first
- [ ] Update `store_versions.ts` comment from "NOT YET ACTIVE" to document the active mechanism
- [ ] Consolidate the two `check_and_clear_idb_table*` helpers (single-table in `store_versions.ts`, batch in `core__idb_dexie.ts`)
### [Stores] Refactor — Phase 2c (deferred)
Phases 1, 2a, 2b are complete (see ✅ Completed below). One phase remaining: