13 Commits

Author SHA1 Message Date
Scott Idem
bbab9e7c8c feat(idaa): update default recovery meeting limit to 100 and add 75 to stepper
- Update default qry__limit to 100 in idaa_loc
- Add 75 to limit_steps in recovery meetings query component
- Bump AE_IDAA_LOC_VERSION to 2 to apply changes to existing users
- Update IDAA documentation and TODO__Agents.md with SQL optimization task
- Mark implemented UI/UX ideas as done in documentation
2026-05-18 21:25:09 -04:00
Scott Idem
daf1570781 Updating docs 2026-05-18 18:51:53 -04:00
Scott Idem
c69e40829f feat(idaa): collapsible Meeting Info panel on recovery meetings list
Wrap the data store element in an accordion-style toggle. State persists
in idaa_loc (localStorage) so the user's preference survives page reloads.
Added ds_info_collapsed field to idaa_local_data_struct.recovery_meetings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:59:02 -04:00
Scott Idem
429f38996a refactor(idaa): filter bar polish — cycling sort, centered layout, move into form
- Replace three sort chips with single cycling button (Last Updated → Name A→Z → Name Z→A → repeat)
- Add min-w-36 to sort button to prevent layout bounce between labels
- Move My Meetings chip to filter row (first position) alongside Virtual/In-Person
- Widen filter chips row from max-w-xl to max-w-2xl to match search bar
- Move sort/max/actions section inside <form> so all rows share the same width constraint
- Flatten span wrappers in bottom row — all buttons are direct flex children of justify-center container, fixing left-drift when conditional buttons are hidden
- Add keyed {#each (val)} to Type chip loop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:50:43 -04:00
Scott Idem
6857f1226c refactor(idaa): compact filter bar — sr-only labels, +/- stepper for max results
- Group labels (Location, Type, Sort, Max) moved to sr-only — visually hidden,
  accessible to screen readers. Chips are self-descriptive without them.
- Max results chips replaced with a [−] 150 [+] stepper that steps through
  predefined values [25, 50, 100, 150] (+ 200/500 for trusted_access). Minus/plus
  buttons disable at the ends of the list. limit_steps and limit_idx computed as
  $derived in script so onclick closures have access without @const gymnastics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:18:59 -04:00
Scott Idem
bb84117991 feat(idaa): replace Max Results and Sort By selects with pill chips
- Max: 25 / 50 / 100 / 150 chips for all users; 200 / 500 visible to trusted_access
  staff only (consistent with previous select behavior).
- Sort: Last Updated / Name A-Z / Name Z-A chips; clicking triggers the same
  qry__order_by_li update and search_version bump as the old select onchange.
- Sort logic extracted into set_sort_mode() helper to keep onclick clean.
- Active state: preset-filled-tertiary-400-600; inactive: outlined + opacity-60.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:11:46 -04:00
Scott Idem
7a1099bbbe feat(idaa): replace filter checkboxes/radios with toggle pill chips + update docs
- ae_idaa_comp__event_obj_qry.svelte: replace Location checkboxes and Type radio
  inputs with styled pill-chip buttons. Location chips (Virtual / In-Person) are
  independent toggles; Type chips (All / IDAA / Caduceus / Family Recovery) are
  mutually exclusive — clicking the active chip deselects back to All. Chips fire
  the reactive search $effect directly via store updates; no explicit trigger needed.
  Remote First dev toggle preserved in edit mode, now inline with filter chips.
- CLIENT__IDAA_and_customized_mods.md: update Recovery Meetings filter/sort docs,
  add My Meetings / favorites section, correct idaa_loc and idaa_sess store schemas,
  bump Last Verified date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:08:01 -04:00
Scott Idem
3a81887c56 feat(idaa): add guided empty state for filtered results + star button on meeting detail
- +page.svelte: when search returns zero results and filters are active, show
  "No meetings found for these filters" with a one-click "Clear all filters" button
  instead of the bare no-results message. The 8s cache-reset escape hatch is
  unchanged and still fires only when zero results appear with no filters set.
- [event_id]/+page.svelte: add star/favorites button to the detail page nav bar
  alongside Back/Edit. Loads the same idaa_meetings_favorites data_store record
  on mount; PATCHes the shared record on toggle. State is optimistic with rollback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:00:51 -04:00
Scott Idem
730fb19d60 refactor(idaa): migrate favorites storage from mod_meetings_json to data_store
Favorites are now stored in a dedicated data_store record (code:
idaa_meetings_favorites, scoped to the IDAA account_id) so toggling
never touches ae_event rows or their updated_on timestamps. Structure:
{ [novi_uuid]: [event_id, ...] }. Pre-created DB records for dev (id 150)
and live IDAA (id 151) accounts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:38:05 -04:00
Scott Idem
b32fb05138 chore(idaa): note updated_on side effect on favorites toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:16:22 -04:00
Scott Idem
12429ccf2e feat(idaa): add My Meetings favorites for recovery meetings
Store Novi UUID list in event.mod_meetings_json.favorite so favorites
persist cross-browser without requiring Novi API write access. Optimistic
IDB update with API rollback on failure. Star button uses inline styles
to override Bootstrap v3 iframe CSS conflicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:09:05 -04:00
Scott Idem
2d552b36fd Made the IDAA ideas more staff friendly.
The IDAA Posts now show the person's name even if anonymous.
2026-05-18 15:01:52 -04:00
Scott Idem
3ed1a2a6c4 Documentation updates with IDAA ideas. 2026-05-18 08:56:53 -04:00
12 changed files with 1338 additions and 396 deletions

View File

@@ -0,0 +1,569 @@
# 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 — ✅ Implemented 2026-05-18
**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.
**Implemented:** `src/routes/idaa/(idaa)/recovery_meetings/+page.svelte`. `has_active_filters`
derived checks `qry__physical`, `qry__virtual`, `qry__type`, and `qry__fulltext_str`. Empty
state branches on `has_active_filters`: active filters → guided message + "Clear Filters"
button; no active filters → existing escape-hatch flow (timed "Refresh Meeting Cache" after
8 seconds).
---
### 2. Quick-filter chips below the search bar — ✅ Implemented 2026-05-18
**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`.
**Implemented:** `ae_idaa_comp__event_obj_qry.svelte`. Chips replaced the old
checkbox/radio/select UI entirely rather than layering on top. Two chip rows:
Row 1 — My Meetings (first), Virtual, In-Person. Row 2 — All / IDAA / Caduceus /
Family Recovery type chips. Cycling sort button replaces separate sort options
(see item below). Max Results uses a +/ stepper. Sort and max are in a third
row below the chips, inside the same `<form>` constraint.
---
### 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" — ✅ Implemented 2026-05-18
**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.
**Implemented:** Star toggle on the `[event_id]` detail page
(`src/routes/idaa/(idaa)/recovery_meetings/[event_id]/+page.svelte`).
"My Meetings" filter chip is first in the filter chip row on the list page.
**Implementation differs from proposal:** favorites stored server-side in a
`data_store` record (code: `idaa_meetings_favorites`) as a UUID-keyed JSON map
rather than in `$idaa_loc` — this means favorites persist across browsers and
devices without Novi write capability. Pinning favorites to the top of the list
was not implemented; the filter chip shows only favorites instead.
---
### 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.
---
---
### 17. Cycling sort button — ✅ Implemented 2026-05-18
**Problem:** Three separate sort chip buttons (Last Updated / Name A→Z / Name Z→A) took
too much horizontal space and caused layout bounce as the selected chip changed width.
**Implemented:** Single cycling button in `ae_idaa_comp__event_obj_qry.svelte`.
Clicking advances through `sort_modes` array (Last Updated → Name A→Z → Name Z→A → repeat)
using `$derived` index + `cycle_sort()` function. Button has `min-w-36` to prevent bounce.
Icon changes per mode (fa-clock / fa-sort-alpha-down / fa-sort-alpha-up-alt). A small
fa-redo icon indicates it's a cycling control.
---
### 18. Collapsible "Meeting Info" data store panel — ✅ Implemented 2026-05-18
**Problem:** The `Element_data_store` panel (code: `recovery_meetings_info`) displays
between the filter bar and the meeting results list. Once a member has read it, it
consumes vertical space on every page load and pushes results below the fold, especially
in the Novi iframe on mobile.
**Implemented:** Toggle button wrapping the `<Element_data_store>` in
`src/routes/idaa/(idaa)/recovery_meetings/+page.svelte`. Button shows
"Meeting Info" with a chevron (up = expanded, down = collapsed). Collapse state
persisted in `$idaa_loc.recovery_meetings.ds_info_collapsed` (localStorage) so the
user's preference survives page reloads. New field added to `idaa_local_data_struct`
in `ae_idaa_stores.ts` — no version bump needed (existing users without the field
get `undefined` which is falsy = expanded, the correct default).
---
## 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

@@ -3,7 +3,7 @@
**Client:** International Doctors in Alcoholics Anonymous (IDAA)
**Module Path:** `src/routes/idaa/`
**State Stores:** `src/lib/stores/ae_idaa_stores.ts`
**Last Updated:** 2026-03-09 (Novi UUID verification upgrade)
**Last Updated:** 2026-05-18 (Default limit and stepper update)
---
@@ -376,12 +376,19 @@ Recovery Meetings reuses the Aether Events object to represent AA recovery meeti
### Search Filters
Members can filter meetings by:
- **Fulltext search** — name, location
- **Physical** — in-person meetings
- **Virtual** — online meetings (Zoom, Google Meet, etc.)
- **Meeting type** — specific meeting format categories
- **Fulltext search** — name, location, day of week, contacts (debounced 250ms; uses SWR pattern)
- **Virtual** — online meetings (Zoom, Jitsi, other)
- **In-person** — physical location meetings
- **Meeting type** — IDAA / Caduceus / Family Recovery
- **My Meetings** — star toggle; shows only meetings the member has starred (favorites)
Search is debounced (250ms) and uses the standard Aether SWR pattern.
**Sort options:** Last Updated (default), Meeting Name AZ, Meeting Name ZA.
**Empty state behavior:**
- Zero results with active filters → "No meetings found for these filters" + "Clear all filters" button
- Zero results with no filters → bare message shown, then after 8s a "Refresh Meeting Cache" escape hatch appears (clears IDB and re-fetches from API — indicates a stale-cache problem, not a real empty set)
Search uses the standard Aether SWR pattern (IDB cache returned immediately, then API refreshes in background).
### Search Architecture — What Is and Isn't Searched
@@ -398,6 +405,22 @@ has not yet been added to the searchable fields whitelist in the API.
- Frontend: add `contact_li_json_ext` as an OR condition in `search__event()`, and update
the local IDB fast-path filter to parse `contact_li_json` for instant cache results
### My Meetings (Favorites)
Members can star meetings to build a personal "My Meetings" list. The star toggle appears:
- On each card in the meeting list (`ae_idaa_comp__event_obj_li.svelte`)
- On the meeting detail page nav bar (`[event_id]/+page.svelte`)
Favorites are stored in the `data_store` table (code: `idaa_meetings_favorites`, scoped to the
IDAA account). The record's `json` field holds `{ [novi_uuid]: [event_id, ...] }` — one shared
record per account containing all members' favorites. This means:
- Favorites persist across browsers and devices (server-side)
- Does **not** write to `ae_event` rows (avoiding the `ON UPDATE current_timestamp()` side effect)
- Known last-write-wins race condition if two members toggle simultaneously — acceptable for ~1000 members
- Pre-created DB records: ID 150 (`gaTKSVPagFj`, account_id=1, dev/demo), ID 151 (`knJh8zhyKT0`, account_id=13, live IDAA)
The star button uses inline styles (not `.btn`) to avoid Bootstrap v3 box-model overrides in the iframe.
### Edit Form — Sections and Key Fields
The edit form (`ae_idaa_comp__event_obj_id_edit.svelte`) is organized into these sections.
@@ -604,18 +627,36 @@ Stores Novi auth context and per-submodule query settings:
novi_jitsi_mod_li: string[] // Jitsi moderator UUIDs
archives: { enabled, hidden, limit, offset, edit__archive_obj, edit__archive_content_obj }
bb: { enabled, hidden, limit, offset, edit__post_obj, edit__post_comment_obj }
recovery_meetings: { qry__fulltext_str, qry__physical, qry__virtual, qry__type, qry__limit, edit__event_obj }
bb: { enabled, hidden, limit, offset, edit__post_obj, edit__post_comment_obj,
qry__enabled, qry__hidden, qry__limit, qry__offset, qry__order_by, qry__order_by_li }
recovery_meetings: {
qry__enabled, qry__hidden, qry__limit, qry__offset,
qry__fulltext_str, qry__physical, qry__virtual, qry__type,
qry__order_by, qry__order_by_li,
qry__favorites_only, // true = show only starred meetings (My Meetings filter)
edit__event_obj // null or event_id string when edit form is open
}
}
```
### `idaa_sess` (sessionStorage — cleared on tab close)
### `idaa_sess` (in-memory only — resets on page load)
UI state per submodule:
```typescript
{
archives: { qry__status, show__modal_edit__archive_id, show__modal_view__archive_id, obj_changed }
bb: { qry__status, show__modal_edit__post_id, show__modal_view__post_id, obj_changed }
recovery_meetings: { qry__status, show__modal_edit, show__modal_view, attend_platform, obj_changed }
archives: { qry__status, show__modal_edit__archive_id, show__modal_view__archive_id,
show__modal_edit__archive_content_id, show__modal_view__archive_content_id, obj_changed }
bb: { qry__status, edit__post_obj, show__inline_edit__post_obj, show__modal_edit__post_id,
show__modal_view__post_id, obj_changed }
recovery_meetings: {
qry__status, // null | 'loading' | 'done' | 'error'
qry__fulltext_str, // session-only copy (separate from persisted loc copy)
search_version, // incremented to trigger a new search cycle
edit__event_obj, // null | event_id — controls edit form visibility
show__modal_edit, show__modal_view,
show__modal_edit__event_id, show__modal_view__event_id,
attend_platform, // 'Zoom' | 'Jitsi' | null — platform selected in virtual attend section
obj_changed
}
}
```
@@ -780,4 +821,4 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
---
**Document Status:** ✅ Current
**Last Verified:** 2026-05-06added Module 5: Jitsi Reports (grouped view, UUID exclusion, known-meeting whitelist, UUID-based unique counts); fixed route tree (`jitsi_reports/` is inside `(idaa)/`)
**Last Verified:** 2026-05-18Recovery Meetings: updated default limit to 100 and added 75 to limit stepper; added My Meetings / favorites (data_store-backed, star button on list + detail page), guided empty state for filtered zero-results, corrected filter/sort descriptions; updated `idaa_loc` and `idaa_sess` store schemas to match actual fields

View File

@@ -0,0 +1,58 @@
# IDAA Recovery Meetings: UI/UX Improvement Roadmap
This document outlines proposed enhancements for the IDAA Recovery Meeting module. The goal is to make it easier for members to find and attend meetings, especially on mobile devices, while providing IDAA staff with better tools to manage meeting data quality.
## 🏆 The "Big Wins" (Highest Member Impact)
### 1. Automatic Timezone Conversion
* **The Problem:** Meetings currently show their "native" time (e.g., 7:00 PM Central). Members must manually calculate the time for their own location.
* **The Fix:** The app will automatically detect the member's local timezone and show a converted time side-by-side (e.g., *"7:00 PM Central — 8:00 PM your time"*).
### 2. "Live Now" & "Todays Meetings"
* **The Fix:**
* **Live Now:** A high-visibility green "LIVE" badge will pulse next to meetings currently in progress.
* **Todays Section:** A dedicated section at the very top of the list will show only meetings happening today, sorted by time, so members don't have to scroll through the full 140+ meeting list.
### 3. Clearer Meeting Schedules
* **The Problem:** Days of the week are currently listed as a flat string (Sunday Monday Wednesday).
* **The Fix:** Convert schedules into natural language one-liners: *"Mondays, Wednesdays, and Fridays at 7:00 PM."* This is much faster for the human eye to scan.
### 4. Favorites ("My Meetings")
* **The Fix:** Members can "Star" their regular meetings. These favorites will be pinned to the top of their list for one-tap access every week.
### 5. "Add to Calendar"
* **The Fix:** A button to automatically add a recurring meeting to a members Google, Apple, or Outlook calendar, including the Zoom/Jitsi link in the calendar event description.
---
## 🛡️ Staff Tools & Data Quality
### 6. "Confirmed Only" Default View
* **The Strategy:** To encourage meeting chairs to keep their information current, we propose defaulting the list to show **only** meetings confirmed by the Central Office.
* **Member Benefit:** Higher confidence that the meeting they are about to join is active and the link is correct.
* **Staff Benefit:** Creates a natural incentive for chairs to contact IDAA to get "Verified," as unverified meetings would require an extra click to see.
### 7. Mobile-Friendly "Not Confirmed" Explanations
* **The Problem:** On mobile, the warning badge for unconfirmed meetings doesn't explain *why* it's there or *how* to fix it.
* **The Fix:** Tapping the badge will show a simple popup: *"This meeting hasn't been verified recently. If you are the chair, please email info@idaa.org to confirm."*
---
## 📱 Ease-of-Use & Mobile Polishing
### 8. Prominent "Join" Buttons & Easy Sharing
* **The Fix:** For virtual meetings, we will move the "Join Zoom" button to a prominent, full-width position at the top of the card. We will also add a "Share" button so members can easily text a meeting link to a sponsee.
### 9. Simplified "Quick-Filter" Chips
* **The Fix:** Instead of small checkboxes, we will add large "Chips" (buttons) for common filters: `[🖥 Virtual]` `[🏠 In-Person]` `[🩺 IDAA]` `[Caduceus]`. These are much easier to tap on a phone screen.
### 10. Intelligent "No Results" Guidance
* **The Problem:** If a member filters too narrowly (e.g., "Caduceus meetings in Hawaii on Tuesdays"), they just see a blank screen.
* **The Fix:** A helpful prompt will appear: *"No meetings found for these filters. [Clear all filters →]"* to prevent members from thinking the app is broken.
---
### Next Steps
1. **Feedback:** Staff identifies which 34 items are the highest priority for the next update.
2. **Prototype:** We implement the high-priority items in the testing environment for staff review.
3. **Deployment:** Changes are pushed live to the IDAA website.

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:
@@ -225,6 +232,11 @@ suddenly jumps to 0 errors, verify it's not because a bad `.d.ts` replaced a pac
- `src/routes/idaa/(idaa)/recovery_meetings/+page.svelte` fast-path IDB filter: parse
`contact_li_json` and include contact names/emails in the local text match check.
- [ ] **[IDAA] Optimize Recovery Meetings SQL VIEW and indexes.**
The current search query can be taxing on the server. With ~150 active meetings, the view
logic and supporting indexes need a performance review to ensure fast responses as the
database grows. (Requested 2026-05-18)
- [ ] **[IDAA / Events] Audit `default_qry_str` coverage in other event search pages.**
The backend was updated 2026-03-31 to expose `default_qry_str` in API responses.
Frontend fix applied to Recovery Meetings (`+page.svelte` + `properties_to_save`).

View File

@@ -84,7 +84,7 @@ const idaa_local_data_struct: key_val = {
qry__enabled: 'enabled', // all, disabled, enabled
qry__hidden: 'not_hidden', // all, hidden, not_hidden
qry__limit: 150,
qry__limit: 100,
qry__order_by: 'updated_on', // For the IDB index query; name, updated_on/created_on
qry__order_by_li: {
priority: 'DESC',
@@ -98,7 +98,16 @@ const idaa_local_data_struct: key_val = {
qry__fulltext_str: null,
qry__physical: null,
qry__type: null,
qry__virtual: null
qry__virtual: null,
// Favorites filter — when true, only show meetings the member has starred.
// Favorites are stored server-side in event.mod_meetings_json.favorite (array of Novi UUIDs),
// so they persist across browsers without requiring a Novi API write capability.
qry__favorites_only: false,
// Collapse the "Meeting Info" data store panel between the search bar and results.
// Persisted so the user's preference survives page reloads.
ds_info_collapsed: false
}
};

View File

@@ -32,7 +32,7 @@
export const AE_LOC_VERSION = 2; // Bumped 2026-03-30: force-clear stale site_cfg_json (novi_idaa_api_key missing bug)
export const AE_EVENTS_LOC_VERSION = 1;
export const AE_IDAA_LOC_VERSION = 1; // Added 2026-03-30: was missing, no wipe mechanism existed
export const AE_IDAA_LOC_VERSION = 2; // Bumped 2026-05-18: change default qry__limit from 150 to 100
export const AE_PRES_MGMT_LOC_VERSION = 1; // Added 2026-04-02: new standalone PersistedState store
export const AE_BADGES_LOC_VERSION = 1; // Added 2026-04-02: promoted from events_loc.badges
export const AE_LEADS_LOC_VERSION = 1; // Added 2026-04-03: promoted from events_loc.leads

View File

@@ -114,7 +114,20 @@ $effect(() => {
<section
class="ae_section ae_meta post__meta mt-4 flex flex-row items-center justify-between gap-2 text-xs text-gray-500">
<div class="ae_group flex flex-col gap-1">
{#if $lq__post_obj?.anonymous}
{#if $ae_loc.trusted_access && $lq__post_obj?.anonymous}
<div class="post__posted_by">
Posted by: <span class="fas fa-user-secret"></span>
<span class="post__full_name">
{$lq__post_obj?.full_name}
*Anonymous*
</span>
{#if $ae_loc.trusted_access && $lq__post_obj?.email}
<a
href="mailto:{$lq__post_obj?.email}?subject=IDAA BB Post"
>{$lq__post_obj?.email}</a>
{/if}
</div>
{:else if $lq__post_obj?.anonymous}
<div class="post__posted_by">
Posted by: <span class="fas fa-user-secret"></span>
<span class="post__full_name">Anonymous</span>
@@ -251,7 +264,20 @@ $effect(() => {
class="post_comment__posted_by comment_by_container">
<span class="comment_by_lable"
>Comment by:</span>
{#if post_comment_obj.anonymous}
{#if $ae_loc.trusted_access && post_comment_obj.anonymous}
<span class="fas fa-user-secret"></span>
<span
class="comment_by_full_name anonymous"
>
{post_comment_obj.full_name}
*Anonymous*
</span>
{#if $ae_loc.trusted_access && post_comment_obj?.email}
<a
href="mailto:{post_comment_obj?.email}?subject=IDAA BB Post Comment"
>{post_comment_obj?.email}</a>
{/if}
{:else if post_comment_obj.anonymous}
<span class="fas fa-user-secret"></span>
<span
class="comment_by_full_name anonymous"

View File

@@ -160,7 +160,22 @@ onMount(() => {
<div
class="ae_footer ae_meta post__meta m-1 flex flex-row items-center justify-center gap-2 text-sm text-gray-500/80">
{#if idaa_post_obj.anonymous}
{#if $ae_loc.trusted_access && idaa_post_obj.anonymous}
<div class="post__posted_by">
<span class="text-xs">Posted by:</span>
<span class="fas fa-user-secret"></span>
<span class="post__full_name">
{idaa_post_obj.full_name}
*Anonymous*
</span>
{#if idaa_post_obj.email}
(<a
href="mailto:{idaa_post_obj.email}?subject=IDAA BB Post"
>{idaa_post_obj.email}</a
>)
{/if}
</div>
{:else if idaa_post_obj.anonymous}
<div class="post__posted_by">
<span class="text-xs">Posted by:</span>
<span class="fas fa-user-secret"></span>

View File

@@ -73,6 +73,14 @@ let no_results_no_filters = $derived(
!($idaa_loc.recovery_meetings.qry__fulltext_str?.trim())
);
// True when any filter dimension is active — drives the guided empty state.
let has_active_filters = $derived(
!!$idaa_loc.recovery_meetings.qry__physical ||
!!$idaa_loc.recovery_meetings.qry__virtual ||
!!$idaa_loc.recovery_meetings.qry__type ||
!!($idaa_loc.recovery_meetings.qry__fulltext_str?.trim())
);
let show_cache_reset_btn = $state(false);
let cache_reset_timer: any = null;
@@ -111,6 +119,13 @@ async function handle_cache_reset() {
}
$idaa_sess.recovery_meetings.search_version++;
}
function clear_filters() {
$idaa_loc.recovery_meetings.qry__physical = null;
$idaa_loc.recovery_meetings.qry__virtual = null;
$idaa_loc.recovery_meetings.qry__type = null;
$idaa_loc.recovery_meetings.qry__fulltext_str = null;
$idaa_sess.recovery_meetings.search_version++;
}
// ─────────────────────────────────────────────────────────────────────────────────────
// Standardized Reactive Search Pattern (Aether UI V3)
@@ -404,11 +419,34 @@ if (browser) {
<Comp__event_obj_qry />
<Element_data_store
ds_code="recovery_meetings_info"
ds_type="html"
class_li="rounded-lg preset-outlined-surface-200-800 m-auto p-2 space-y-2 w-full max-w-xl"
show_edit_btn={true} />
<div class="w-full max-w-xl mx-auto space-y-1">
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.ds_info_collapsed =
!($idaa_loc.recovery_meetings.ds_info_collapsed ?? false);
}}
class="novi_btn btn btn-sm w-full flex items-center justify-between
rounded-lg preset-outlined-surface-200-800 hover:preset-tonal-surface
opacity-60 hover:opacity-100 transition-all px-3 py-1"
title={$idaa_loc.recovery_meetings.ds_info_collapsed
? 'Show meeting info'
: 'Collapse meeting info'}>
<span class="text-sm">
<span class="fas fa-info-circle mr-1 text-xs"></span>Meeting Info
</span>
<span class="fas text-xs opacity-60
{$idaa_loc.recovery_meetings.ds_info_collapsed ? 'fa-chevron-down' : 'fa-chevron-up'}">
</span>
</button>
{#if !($idaa_loc.recovery_meetings.ds_info_collapsed ?? false)}
<Element_data_store
ds_code="recovery_meetings_info"
ds_type="html"
class_li="rounded-lg preset-outlined-surface-200-800 m-auto p-2 space-y-2 w-full"
show_edit_btn={true} />
{/if}
</div>
{#if Array.isArray(event_id_li) && event_id_li.length}
<Comp__event_obj_li_wrapper
@@ -441,24 +479,40 @@ if (browser) {
</button>
</div>
{:else}
<div
class="ae_highlight ae_padding_md ae_row ae_flex_justify_center">
No recovery meetings found matching your criteria.
</div>
{#if show_cache_reset_btn}
<!-- Escape hatch: surfaces after 8s when zero results + no active filters.
With ~140 active meetings, zero unfiltered results always indicates
stale IDB data. Clears the event cache and triggers a fresh API fetch. -->
{#if has_active_filters}
<!-- Guided empty state: filters are active but returned no results.
Distinct from the zero-unfiltered-results path (which indicates a data problem).
Here the member may simply have narrowed too far — offer a one-click escape. -->
<div class="ae_highlight ae_padding_md ae_row ae_flex_justify_center flex-col gap-2 text-center">
<p class="text-sm opacity-75">Still not seeing meetings? Your local cache may be out of date.</p>
<p>No meetings found for these filters.</p>
<button
type="button"
class="btn btn-sm preset-tonal-warning m-auto"
onclick={handle_cache_reset}>
<span class="fas fa-sync-alt m-1"></span>
Refresh Meeting Cache
class="btn btn-sm preset-tonal-primary m-auto"
onclick={clear_filters}>
<span class="fas fa-times m-1"></span>
Clear all filters
</button>
</div>
{:else}
<div
class="ae_highlight ae_padding_md ae_row ae_flex_justify_center">
No recovery meetings found matching your criteria.
</div>
{#if show_cache_reset_btn}
<!-- Escape hatch: surfaces after 8s when zero results + no active filters.
With ~140 active meetings, zero unfiltered results always indicates
stale IDB data. Clears the event cache and triggers a fresh API fetch. -->
<div class="ae_highlight ae_padding_md ae_row ae_flex_justify_center flex-col gap-2 text-center">
<p class="text-sm opacity-75">Still not seeing meetings? Your local cache may be out of date.</p>
<button
type="button"
class="btn btn-sm preset-tonal-warning m-auto"
onclick={handle_cache_reset}>
<span class="fas fa-sync-alt m-1"></span>
Refresh Meeting Cache
</button>
</div>
{/if}
{/if}
{/if}
</div>

View File

@@ -38,10 +38,25 @@ import {
import Event_obj_id_edit from '../ae_idaa_comp__event_obj_id_edit.svelte';
import Event_obj_id_view from '.././ae_idaa_comp__event_obj_id_view.svelte';
import Help_tech from '$lib/app_components/e_app_help_tech.svelte';
import { api } from '$lib/api/api';
// *** Quickly pull out data from parent(s)
let ae_acct = $derived(data[data.account_id]);
// Favorites stored in data_store (code: idaa_meetings_favorites, scoped to IDAA account_id).
// Same shared record used by the meeting list view; see ae_idaa_comp__event_obj_li.svelte.
let ds_fav_id = $state<string | null>(null);
let ds_fav_json = $state<Record<string, string[]>>({});
let fav_in_progress = $state(false);
let event_id_for_fav = $derived(ae_acct?.slct?.event_id ?? null);
let is_fav = $derived.by(() => {
const my_uuid = $idaa_loc.novi_uuid;
if (!my_uuid || !event_id_for_fav) return false;
const my_favs = ds_fav_json[my_uuid];
return Array.isArray(my_favs) && my_favs.includes(event_id_for_fav);
});
$idaa_sess.recovery_meetings.edit__event_obj = null;
$effect(() => {
if (!ae_acct) return;
@@ -131,6 +146,55 @@ onMount(() => {
}
});
onMount(async () => {
if (!$idaa_loc.novi_uuid || !$ae_api?.base_url) return;
try {
const result = await api.get_data_store({
api_cfg: $ae_api,
code: 'idaa_meetings_favorites'
});
const rec_id = result?.data_store_id || result?.id;
if (rec_id) {
ds_fav_id = rec_id;
let raw = result.json ?? result.json_str ?? null;
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch { raw = {}; }
}
ds_fav_json = (raw as Record<string, string[]>) ?? {};
}
} catch (err) {
console.warn('[favorites] Failed to load favorites data store:', err);
}
});
async function toggle_favorite() {
const my_uuid = $idaa_loc.novi_uuid;
const event_id = event_id_for_fav;
if (!my_uuid || !event_id || !ds_fav_id || fav_in_progress) return;
fav_in_progress = true;
const current_user_list: string[] = Array.isArray(ds_fav_json[my_uuid])
? [...ds_fav_json[my_uuid]] : [];
const new_user_list = is_fav
? current_user_list.filter((id) => id !== event_id)
: [...current_user_list, event_id];
const prev_json = ds_fav_json;
const new_full_json = { ...ds_fav_json, [my_uuid]: new_user_list };
ds_fav_json = new_full_json;
try {
await api.update_ae_obj({
api_cfg: $ae_api,
obj_type: 'data_store',
obj_id: ds_fav_id,
fields: { json: JSON.stringify(new_full_json) }
});
} catch (err) {
console.warn('[favorites] API persist failed, rolling back:', err);
ds_fav_json = prev_json;
} finally {
fav_in_progress = false;
}
}
onDestroy(() => {
if (log_lvl) {
console.log(
@@ -255,6 +319,23 @@ onDestroy(() => {
<!-- <span class="fas fa-times m-1"></span> View Other Meetings -->
</a>
{#if $idaa_loc.novi_uuid && ds_fav_id}
<button
type="button"
onclick={toggle_favorite}
disabled={fav_in_progress}
aria-pressed={is_fav}
style="background:none; border:none; box-shadow:none; padding:4px 8px; cursor:pointer; line-height:1; opacity:{is_fav ? '1' : '0.5'}; transition:opacity 0.15s, color 0.15s;"
title={is_fav ? 'Remove from My Meetings' : 'Add to My Meetings'}>
{#if fav_in_progress}
<span class="fas fa-spinner fa-spin" style="font-size:1rem;"></span>
{:else}
<span class="fas fa-star" style="font-size:1rem; color:{is_fav ? '#d97706' : 'currentColor'};"></span>
{/if}
<span style="font-size:0.85rem; margin-left:3px;">{is_fav ? 'In My Meetings' : 'Add to My Meetings'}</span>
</button>
{/if}
<!-- View (default)/Edit event_id toggle -->
{#if $idaa_sess.recovery_meetings.edit__event_obj}
<button

View File

@@ -19,6 +19,7 @@ let {
}: Props = $props();
// *** Import Svelte specific
import { onMount } from 'svelte';
// *** Import Aether specific variables and functions
import { ae_util } from '$lib/ae_utils/ae_utils';
@@ -37,8 +38,86 @@ import {
idaa_slct,
idaa_trig
} from '$lib/stores/ae_idaa_stores';
import { api } from '$lib/api/api';
import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
// Favorites are stored in a dedicated data_store record (code: idaa_meetings_favorites,
// scoped to the IDAA account_id) so toggling never touches ae_event rows or their
// updated_on timestamps. Structure: { [novi_uuid]: [event_id, ...] }.
// One shared record per account — known race condition if two members toggle at
// the exact same moment (last write wins). Acceptable for ~1000 members.
let ds_fav_id = $state<string | null>(null);
let ds_fav_json = $state<Record<string, string[]>>({});
// Tracks event IDs currently being toggled to prevent double-clicks
let favorites_in_progress = $state<Set<string>>(new Set());
onMount(async () => {
if (!$idaa_loc.novi_uuid || !$ae_api?.base_url) return;
try {
const result = await api.get_data_store({
api_cfg: $ae_api,
code: 'idaa_meetings_favorites'
});
const rec_id = result?.data_store_id || result?.id;
if (rec_id) {
ds_fav_id = rec_id;
let raw = result.json ?? result.json_str ?? null;
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch { raw = {}; }
}
ds_fav_json = (raw as Record<string, string[]>) ?? {};
}
} catch (err) {
console.warn('[favorites] Failed to load favorites data store:', err);
}
});
function check_fav(event_obj: Record<string, unknown>): boolean {
const my_uuid = $idaa_loc.novi_uuid;
if (!my_uuid) return false;
const my_favs = ds_fav_json[my_uuid];
return Array.isArray(my_favs) && my_favs.includes(event_obj.event_id as string);
}
async function toggle_favorite(event_obj: Record<string, unknown>) {
const my_uuid = $idaa_loc.novi_uuid;
const event_id = event_obj.event_id as string;
if (!my_uuid || !event_id || !ds_fav_id || favorites_in_progress.has(event_id)) return;
favorites_in_progress.add(event_id);
const current_user_list: string[] = Array.isArray(ds_fav_json[my_uuid])
? [...ds_fav_json[my_uuid]]
: [];
const already_fav = current_user_list.includes(event_id);
const new_user_list = already_fav
? current_user_list.filter((id) => id !== event_id)
: [...current_user_list, event_id];
const prev_json = ds_fav_json;
const new_full_json = { ...ds_fav_json, [my_uuid]: new_user_list };
// Optimistic update — ds_fav_json is reactive $state, so check_fav() re-evaluates
// instantly everywhere it's called (derived list sort + each block template).
ds_fav_json = new_full_json;
try {
await api.update_ae_obj({
api_cfg: $ae_api,
obj_type: 'data_store',
obj_id: ds_fav_id,
fields: { json: JSON.stringify(new_full_json) }
});
} catch (err) {
console.warn('[favorites] API persist failed, rolling back:', err);
ds_fav_json = prev_json;
} finally {
favorites_in_progress.delete(event_id);
}
}
// Derived list of visible items (Refactored 2026-02-05)
//
// WHY: The parent search logic fetches matching records into the local IndexedDB.
@@ -78,8 +157,27 @@ let visible_event_obj_li = $derived.by(() => {
`visible_event_obj_li: Input=${list.length}, Output=${filtered.length} (trusted=${$ae_loc.trusted_access})`
);
// Favorites: pin starred meetings to the top of the list.
// Runs before the limit slice so favorites are never cut off.
const my_uuid = $idaa_loc.novi_uuid;
if (my_uuid) {
filtered.sort((a, b) => {
const a_fav = check_fav(a);
const b_fav = check_fav(b);
if (a_fav && !b_fav) return -1;
if (!a_fav && b_fav) return 1;
return 0; // stable — preserves existing sort within each group
});
}
// Favorites-only filter — applied after sort so starred items are already at top
const starred_only = $idaa_loc.recovery_meetings.qry__favorites_only;
const shown = starred_only && my_uuid
? filtered.filter((item) => check_fav(item))
: filtered;
// Final safety slice to respect the user's limit selection
return filtered.slice(0, $idaa_loc.recovery_meetings.qry__limit || 150);
return shown.slice(0, $idaa_loc.recovery_meetings.qry__limit || 150);
});
$effect(() => {
if (log_lvl) {
@@ -129,10 +227,33 @@ $effect(() => {
flex-row items-center justify-between gap-2 text-2xl
font-bold
">
<span
class="fas fa-calendar-day text-neutral-800/80"
></span>
{idaa_event_obj?.name ?? 'Recovery Meeting'}
<span class="flex flex-row items-center gap-2">
<span
class="fas fa-calendar-day text-neutral-800/80"
></span>
{idaa_event_obj?.name ?? 'Recovery Meeting'}
</span>
{#if $idaa_loc.novi_uuid}
{@const fav = check_fav(idaa_event_obj)}
{@const pending = favorites_in_progress.has(idaa_event_obj?.event_id)}
<!-- Inline style resets Bootstrap v3 .btn defaults (the iframe
injects idaa.css which applies its own button box model).
No Skeleton preset classes here — they fight with Bootstrap. -->
<button
type="button"
onclick={() => toggle_favorite(idaa_event_obj)}
disabled={pending}
aria-pressed={fav}
style="background:none; border:none; box-shadow:none; padding:2px 6px; cursor:pointer; line-height:1; opacity:{fav ? '1' : '0.35'}; transition:opacity 0.15s, color 0.15s;"
title={fav ? 'Remove from My Meetings' : 'Add to My Meetings'}>
{#if pending}
<span class="fas fa-spinner fa-spin" style="font-size:1.1rem;"></span>
{:else}
<span class="fas fa-star" style="font-size:1.1rem; color:{fav ? '#d97706' : 'currentColor'};"></span>
{/if}
</button>
{/if}
</h3>
<div
@@ -557,6 +678,13 @@ $effect(() => {
<span class="fas fa-spinner fa-spin m-1"></span>
Loading...
</div>
{:else if $idaa_loc.recovery_meetings.qry__favorites_only}
<div
class="ae_highlight ae_padding_md ae_row ae_flex_justify_center flex-col gap-2 text-center">
<span class="fas fa-star text-2xl text-yellow-400"></span>
<p>No starred meetings yet.</p>
<p class="text-sm opacity-75">Tap the ★ on any meeting card to add it to My Meetings.</p>
</div>
{:else}
<div
class="ae_highlight ae_padding_md ae_row ae_flex_justify_center">

View File

@@ -37,6 +37,14 @@ import Help_tech from '$lib/app_components/e_app_help_tech.svelte';
let ae_promises: key_val = $state({});
// Predefined limit steps for the +/- stepper. Trusted staff get larger options
// since they often query with disabled/hidden meetings included.
let limit_steps = $derived($ae_loc.trusted_access ? [25, 50, 75, 100, 150, 200, 500] : [25, 50, 75, 100, 150]);
let limit_idx = $derived.by(() => {
const idx = limit_steps.indexOf($idaa_loc.recovery_meetings.qry__limit);
return idx >= 0 ? idx : limit_steps.length - 1;
});
// *** Functions and Logic
/**
* Reactive Search Trigger
@@ -56,6 +64,25 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
fn(event);
};
}
const sort_modes = [
{ mode: 'updated_on', label: 'Last Updated', icon: 'fa-clock', order_by_li: { priority: 'DESC', sort: 'DESC', updated_on: 'DESC', created_on: 'DESC', name: 'ASC' } },
{ mode: 'name_asc', label: 'Name A→Z', icon: 'fa-sort-alpha-down', order_by_li: { priority: 'DESC', sort: 'DESC', name: 'ASC', updated_on: 'DESC', created_on: 'DESC' } },
{ mode: 'name_desc', label: 'Name Z→A', icon: 'fa-sort-alpha-up-alt', order_by_li: { priority: 'DESC', sort: 'DESC', name: 'DESC', updated_on: 'DESC', created_on: 'DESC' } }
];
let sort_cycle_idx = $derived.by(() => {
const idx = sort_modes.findIndex(m => m.mode === $idaa_loc.recovery_meetings.qry__order_by);
return idx >= 0 ? idx : 0;
});
let current_sort = $derived(sort_modes[sort_cycle_idx]);
function cycle_sort() {
const next = sort_modes[(sort_cycle_idx + 1) % sort_modes.length];
$idaa_loc.recovery_meetings.qry__order_by = next.mode;
$idaa_loc.recovery_meetings.qry__order_by_li = next.order_by_li;
handle_search_trigger();
}
</script>
<!-- preset-filled-surface-200-800 -->
@@ -160,369 +187,291 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
</button>
</div>
<!-- Filter chips: My Meetings + Location + Type -->
<div
class="border-surface-300-700 flex w-full max-w-xl flex-row flex-wrap items-center justify-start gap-2 border-b p-1">
<span class="form-check-label d-inline-block text-xs">
Location?
</span>
<!-- <div class="ae_row ae_flex_justify_around ae_width_md"> -->
<label
class="legend form-check-label d-inline-block inline w-auto flex-row items-center justify-center gap-1 text-sm font-semibold">
<span> Virtual </span>
<input
name="qry_virtual"
type="checkbox"
bind:checked={$idaa_loc.recovery_meetings.qry__virtual}
class="checkbox form-check-input d-inline-block inline" />
</label>
<label
class="legend form-check-label d-inline-block inline w-auto flex-row items-center justify-center gap-1 text-sm font-semibold">
In-person
<input
name="qry_physical"
type="checkbox"
bind:checked={$idaa_loc.recovery_meetings.qry__physical}
class="checkbox form-check-input d-inline-block inline" />
</label>
<!-- </div> -->
</div>
class="border-surface-300-700 flex w-full max-w-2xl flex-row flex-wrap items-center justify-center gap-x-4 gap-y-1 border-b p-1">
<div
class="border-surface-300-700 flex w-full max-w-xl flex-row flex-wrap items-center justify-start gap-2 border-b p-1">
<span class="form-check-label d-inline-block text-xs"> Type? </span>
<!-- My Meetings: favorites filter — shown only to logged-in Novi members -->
{#if $idaa_loc.novi_uuid}
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.qry__favorites_only =
!$idaa_loc.recovery_meetings.qry__favorites_only;
}}
class="novi_btn btn-star btn btn-sm transition-all
{$idaa_loc.recovery_meetings.qry__favorites_only
? 'preset-filled-warning-300-700'
: 'preset-outlined-surface-300-700 opacity-60 hover:opacity-100'}"
title={$idaa_loc.recovery_meetings.qry__favorites_only
? 'Showing My Meetings only — click to show all'
: 'Show only My Meetings (starred)'}>
<span class="fas fa-star m-1 {$idaa_loc.recovery_meetings.qry__favorites_only ? 'text-yellow-600' : ''}"></span>
My Meetings
</button>
{/if}
<!-- <div class="ae_row ae_flex_justify_around ae_width_100"> -->
<label
class="legend form-check-label d-inline-block inline w-auto flex-row items-center justify-center gap-1 text-sm font-semibold">
<span> All </span>
<input
name="qry_type"
type="radio"
value=""
bind:group={$idaa_loc.recovery_meetings.qry__type}
class="radio form-check-input d-inline-block inline"
title="Show all meeting types" />
</label>
<label
class="legend form-check-label d-inline-block inline w-auto flex-row items-center justify-center gap-1 text-sm font-semibold">
IDAA
<input
name="qry_type"
type="radio"
value="IDAA"
bind:group={$idaa_loc.recovery_meetings.qry__type}
class="radio form-check-input d-inline-block inline"
title="Open to IDAA members only" />
</label>
<label
class="legend form-check-label d-inline-block inline w-auto flex-row items-center justify-center gap-1 text-sm font-semibold">
Caduceus
<input
name="qry_type"
type="radio"
value="Caduceus"
bind:group={$idaa_loc.recovery_meetings.qry__type}
class="radio form-check-input d-inline-block inline"
title="Open to all healthcare workers including those who do not qualify for IDAA" />
</label>
<label
class="legend form-check-label d-inline-block inline w-auto flex-row items-center justify-center gap-1 text-sm font-semibold">
Family Recovery
<input
name="qry_type"
type="radio"
value="Family Recovery"
bind:group={$idaa_loc.recovery_meetings.qry__type}
class="radio form-check-input d-inline-block inline"
title="Open to spouses, parents, and children of medical professionals who have substance use disorder." />
</label>
<!-- Location: independent toggles (a meeting can be both virtual and in-person) -->
<div class="flex flex-row flex-wrap items-center gap-1">
<span class="sr-only">Location:</span>
<button
type="button"
onclick={() => { $idaa_loc.recovery_meetings.qry__virtual = !$idaa_loc.recovery_meetings.qry__virtual; }}
aria-pressed={$idaa_loc.recovery_meetings.qry__virtual}
title="Show virtual / online meetings"
class="novi_btn btn btn-sm transition-all
{$idaa_loc.recovery_meetings.qry__virtual
? 'preset-filled-tertiary-400-600'
: 'preset-outlined-surface-300-700 opacity-60 hover:opacity-100'}">
<span class="fas fa-laptop mr-1"></span>Virtual
</button>
<button
type="button"
onclick={() => { $idaa_loc.recovery_meetings.qry__physical = !$idaa_loc.recovery_meetings.qry__physical; }}
aria-pressed={$idaa_loc.recovery_meetings.qry__physical}
title="Show in-person / face-to-face meetings"
class="novi_btn btn btn-sm transition-all
{$idaa_loc.recovery_meetings.qry__physical
? 'preset-filled-tertiary-400-600'
: 'preset-outlined-surface-300-700 opacity-60 hover:opacity-100'}">
<span class="fas fa-home mr-1"></span>In-Person
</button>
</div>
<span class="ml-auto"></span>
<!-- Type: mutually exclusive; clicking the active chip deselects (goes back to All) -->
<div class="flex flex-row flex-wrap items-center gap-1">
<span class="sr-only">Type:</span>
{#each [
['', 'All', 'Show all meeting types'],
['IDAA', 'IDAA', 'Open to IDAA members only'],
['Caduceus', 'Caduceus', 'Open to all healthcare workers including those who do not qualify for IDAA'],
['Family Recovery', 'Family Recovery', 'Open to spouses, parents, and children of medical professionals in recovery']
] as [val, label, tip] (val)}
{@const active = val === '' ? !$idaa_loc.recovery_meetings.qry__type : $idaa_loc.recovery_meetings.qry__type === val}
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.qry__type =
(val !== '' && $idaa_loc.recovery_meetings.qry__type === val) ? '' : val;
}}
aria-pressed={active}
title={tip}
class="novi_btn btn btn-sm transition-all
{active
? 'preset-filled-secondary-400-600'
: 'preset-outlined-surface-300-700 opacity-60 hover:opacity-100'}">
{label}
</button>
{/each}
</div>
{#if $ae_loc.edit_mode}
<label
class="legend form-check-label d-inline-block inline w-auto flex-row items-center justify-center gap-1 text-sm font-semibold"
title="When enabled, search results are fetched directly from the server first. When disabled, local results are shown instantly while revalidating in the background.">
<span class="text-xs"> Remote First? </span>
class="flex flex-row items-center gap-1 text-xs opacity-60 cursor-pointer"
title="When enabled, search results are fetched directly from the server first. Disable for instant local results with background revalidation.">
<input
name="qry_remote_first"
type="checkbox"
bind:checked={
$idaa_loc.recovery_meetings.qry__remote_first
}
class="checkbox form-check-input d-inline-block inline" />
bind:checked={$idaa_loc.recovery_meetings.qry__remote_first}
class="checkbox form-check-input d-inline-block" />
Remote First
</label>
{/if}
</div>
</form>
<div
class="ae_group ae_row flex w-full max-w-full flex-row flex-wrap items-center justify-center gap-2 p-1">
<!-- Max events select options -->
<span class="flex flex-row items-center justify-around gap-1">
<label
class="form-group w-32 text-sm md:w-42"
for="qry_limit__events">
Max results:
<div
class="ae_group ae_row flex w-full flex-row flex-wrap items-center justify-center gap-2 p-1">
<!-- Sort: single cycling button -->
<div class="flex flex-row items-center gap-1">
<span class="sr-only">Sort:</span>
<button
type="button"
onclick={cycle_sort}
title="Currently: {current_sort.label} click to change sort order"
class="novi_btn btn btn-sm preset-outlined-surface-300-700 opacity-80 hover:opacity-100 transition-all min-w-36">
<span class="fas {current_sort.icon} mr-1"></span>{current_sort.label}
<span class="fas fa-redo fa-xs ml-1 opacity-40"></span>
</button>
</div>
<select
id="qry_limit__events"
bind:value={$idaa_loc.recovery_meetings.qry__limit}
class="
select preset-tonal-tertiary preset-outline-tertiary-200-800 form-control
col-sm-12
inline-block
w-20
px-1 text-sm
">
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={75}>75</option>
<option value={100}>100</option>
<option value={150}>150</option>
<option value={200} class:hidden={!$ae_loc.trusted_access}
>200</option>
<option value={500} class:hidden={!$ae_loc.trusted_access}
>500</option>
</select>
</label>
<!-- </span> -->
<!-- Max results: +/- stepper through predefined steps -->
<div class="flex flex-row items-center gap-1">
<span class="sr-only">Max results:</span>
<button
type="button"
onclick={() => { if (limit_idx > 0) $idaa_loc.recovery_meetings.qry__limit = limit_steps[limit_idx - 1]; }}
disabled={limit_idx <= 0}
title="Show fewer results"
class="novi_btn btn btn-sm preset-outlined-surface-300-700 transition-all
{limit_idx <= 0 ? 'opacity-30 cursor-not-allowed' : 'opacity-60 hover:opacity-100'}">
<span class="fas fa-minus"></span>
</button>
<span
class="min-w-10 text-center text-sm font-semibold tabular-nums"
title="Max results shown">
{$idaa_loc.recovery_meetings.qry__limit}
</span>
<button
type="button"
onclick={() => { if (limit_idx < limit_steps.length - 1) $idaa_loc.recovery_meetings.qry__limit = limit_steps[limit_idx + 1]; }}
disabled={limit_idx >= limit_steps.length - 1}
title="Show more results"
class="novi_btn btn btn-sm preset-outlined-surface-300-700 transition-all
{limit_idx >= limit_steps.length - 1 ? 'opacity-30 cursor-not-allowed' : 'opacity-60 hover:opacity-100'}">
<span class="fas fa-plus"></span>
</button>
</div>
<!-- Sort by last updated date or by name -->
<!-- <span
class="flex flex-row gap-1 items-center justify-around"
> -->
<label
class="form-group w-32 text-sm md:w-42"
for="qry_order_by__events">
Sort by:
<!-- Staff: toggle hidden events visibility (trusted + edit mode) -->
{#if $ae_loc.edit_mode && $ae_loc.trusted_access && (!$idaa_loc.recovery_meetings.qry__hidden || $idaa_loc.recovery_meetings.qry__hidden == 'not_hidden')}
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.qry__hidden = 'all';
$idaa_loc.recovery_meetings.qry__limit = 200;
}}
class="novi_btn btn_show_recovery_mtg_event ae_btn btn-info btn btn-sm
preset-tonal-secondary preset-outlined-secondary-200-800 hover:preset-filled-secondary-200-800 transition">
<span class="fas fa-eye m-1"></span> Show Hidden Events
</button>
{:else if $ae_loc.trusted_access && $idaa_loc.recovery_meetings.qry__hidden != 'not_hidden'}
<button
type="button"
onclick={() => { $idaa_loc.recovery_meetings.qry__hidden = 'not_hidden'; }}
class="novi_btn btn_hide_recovery_mtg_event ae_btn btn-info btn btn-sm
preset-tonal-secondary preset-outlined-secondary-200-800 hover:preset-filled-secondary-200-800 transition">
<span class="fas fa-eye-slash m-1"></span> Hide Hidden Events
</button>
{/if}
<select
id="qry_order_by__events"
bind:value={$idaa_loc.recovery_meetings.qry__order_by}
onchange={() => {
const mode = $idaa_loc.recovery_meetings.qry__order_by;
if (mode === 'updated_on') {
$idaa_loc.recovery_meetings.qry__order_by_li = {
priority: 'DESC',
sort: 'DESC',
updated_on: 'DESC',
created_on: 'DESC',
name: 'ASC'
};
} else if (mode === 'name_asc') {
$idaa_loc.recovery_meetings.qry__order_by_li = {
priority: 'DESC',
sort: 'DESC',
name: 'ASC',
updated_on: 'DESC',
created_on: 'DESC'
};
} else if (mode === 'name_desc') {
$idaa_loc.recovery_meetings.qry__order_by_li = {
priority: 'DESC',
sort: 'DESC',
name: 'DESC',
updated_on: 'DESC',
created_on: 'DESC'
};
}
handle_search_trigger();
}}
class="
select preset-tonal-tertiary preset-outline-tertiary-200-800 form-control
col-sm-12
inline-block
w-40
px-1 text-sm
">
<option value="updated_on">Last Updated</option>
<option value="name_asc">Meeting Name (A-Z)</option>
<option value="name_desc">Meeting Name (Z-A)</option>
</select>
</label>
</span>
<!-- Staff: toggle disabled events visibility (manager + edit mode) -->
{#if $ae_loc.edit_mode && $ae_loc.manager_access && (!$idaa_loc.recovery_meetings.qry__enabled || $idaa_loc.recovery_meetings.qry__enabled == 'enabled')}
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.qry__hidden = 'all';
$idaa_loc.recovery_meetings.qry__enabled = 'all';
// NOTE: I may re-enable this limit reset later. It is sometimes useful.
// $idaa_loc.recovery_meetings.qry__limit = 500;
}}
class="novi_btn btn_show_recovery_mtg_event ae_btn btn-warning btn btn-sm
preset-tonal-warning preset-outlined-warning-200-800 hover:preset-filled-warning-200-800 transition">
<span class="fas fa-eye m-1"></span> Show Disabled Events
</button>
{:else if $ae_loc.edit_mode && $ae_loc.manager_access && $idaa_loc.recovery_meetings.qry__enabled != 'enabled'}
<button
type="button"
onclick={() => { $idaa_loc.recovery_meetings.qry__enabled = 'enabled'; }}
class="novi_btn btn_hide_recovery_mtg_event ae_btn btn-warning btn btn-sm
preset-tonal-warning preset-outlined-warning-200-800 hover:preset-filled-warning-200-800 transition">
<span class="fas fa-eye-slash m-1"></span> Hide Disabled Events
</button>
{/if}
<span class="flex flex-row items-center justify-around gap-1">
{#if $ae_loc.edit_mode && $ae_loc.trusted_access && (!$idaa_loc.recovery_meetings.qry__hidden || $idaa_loc.recovery_meetings.qry__hidden == 'not_hidden')}
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.qry__hidden = 'all';
$idaa_loc.recovery_meetings.qry__limit = 200;
}}
class="
novi_btn btn_show_recovery_mtg_event ae_btn btn-info
btn btn-sm
preset-tonal-secondary preset-outlined-secondary-200-800 hover:preset-filled-secondary-200-800
transition
">
<span class="fas fa-eye m-1"></span> Show Hidden Events
</button>
{:else if $ae_loc.trusted_access && $idaa_loc.recovery_meetings.qry__hidden != 'not_hidden'}
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.qry__hidden = 'not_hidden';
}}
class="
novi_btn btn_hide_recovery_mtg_event ae_btn btn-info
btn btn-sm
preset-tonal-secondary preset-outlined-secondary-200-800 hover:preset-filled-secondary-200-800
transition
">
<span class="fas fa-eye-slash m-1"></span> Hide Hidden Events
</button>
{/if}
<!-- Create new meeting (Novi members and trusted staff in edit mode) -->
{#if ($ae_loc.trusted_access && $ae_loc.edit_mode) || $idaa_loc.novi_uuid}
<button
type="button"
onclick={() => {
if (!confirm('Create new meeting?')) {
return false;
}
$idaa_slct.event_id = null;
$idaa_slct.event_obj = {};
{#if $ae_loc.edit_mode && $ae_loc.manager_access && (!$idaa_loc.recovery_meetings.qry__enabled || $idaa_loc.recovery_meetings.qry__enabled == 'enabled')}
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.qry__hidden = 'all';
$idaa_loc.recovery_meetings.qry__enabled = 'all';
// NOTE: I may re-enable this limit reset later. It is sometimes useful.
// $idaa_loc.recovery_meetings.qry__limit = 500;
}}
class="
novi_btn btn_show_recovery_mtg_event ae_btn btn-warning
btn btn-sm
preset-tonal-warning preset-outlined-warning-200-800 hover:preset-filled-warning-200-800
transition
">
<span class="fas fa-eye m-1"></span> Show Disabled Events
</button>
{:else if $ae_loc.edit_mode && $ae_loc.manager_access && $idaa_loc.recovery_meetings.qry__enabled != 'enabled'}
<button
type="button"
onclick={() => {
$idaa_loc.recovery_meetings.qry__enabled = 'enabled';
}}
class="
novi_btn btn_hide_recovery_mtg_event ae_btn btn-warning
btn btn-sm
preset-tonal-warning preset-outlined-warning-200-800 hover:preset-filled-warning-200-800
transition
">
<span class="fas fa-eye-slash m-1"></span> Hide Disabled Events
</button>
{/if}
</span>
<span class="flex flex-row items-center justify-around gap-1">
{#if ($ae_loc.trusted_access && $ae_loc.edit_mode) || $idaa_loc.novi_uuid}
<button
type="button"
onclick={() => {
if (!confirm('Create new meeting?')) {
return false;
}
$idaa_slct.event_id = null;
$idaa_slct.event_obj = {};
let novi_uuid = $idaa_loc.novi_uuid;
if (!novi_uuid && typeof localStorage !== 'undefined') {
try {
const ls_val = localStorage.getItem('ae_idaa_loc');
if (ls_val) {
const ls_json = JSON.parse(ls_val);
novi_uuid = ls_json.novi_uuid;
}
} catch (e) {
// Ignore storage errors
let novi_uuid = $idaa_loc.novi_uuid;
if (!novi_uuid && typeof localStorage !== 'undefined') {
try {
const ls_val = localStorage.getItem('ae_idaa_loc');
if (ls_val) {
const ls_json = JSON.parse(ls_val);
novi_uuid = ls_json.novi_uuid;
}
} catch {
// Ignore storage errors
}
}
let data_kv = {
name: 'Change NEW Recovery Meeting Name',
external_person_id: novi_uuid
};
if (log_lvl) {
console.log(
'Creating new event (recovery meeting) with data_kv:',
data_kv
);
}
let data_kv = {
name: 'Change NEW Recovery Meeting Name',
external_person_id: novi_uuid
};
if (log_lvl) {
console.log(
'Creating new event (recovery meeting) with data_kv:',
data_kv
);
}
events_func
.create_ae_obj__event({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: data_kv,
log_lvl: log_lvl
})
.then((results) => {
if (results) {
$idaa_slct.event_id = results.event_id;
$idaa_slct.event_obj = { ...results };
$idaa_sess.recovery_meetings.edit__event_obj =
results.event_id;
events_func
.create_ae_obj__event({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: data_kv,
log_lvl: log_lvl
})
.then((results) => {
if (results) {
$idaa_slct.event_id = results.event_id;
$idaa_slct.event_obj = { ...results };
$idaa_sess.recovery_meetings.edit__event_obj =
results.event_id;
if (log_lvl) {
console.log(
`New event created with ID: ${results.event_id}`
);
}
goto(
`/idaa/recovery_meetings/${results.event_id}`
if (log_lvl) {
console.log(
`New event created with ID: ${results.event_id}`
);
}
})
.catch((error) => {
console.error('Error creating event:', error);
alert('Failed to create new event.');
});
}}
class="
novi_btn btn-tertiary
btn
btn-sm preset-tonal-warning
preset-outlined-warning-200-800 hover:preset-filled-warning-200-800 text-xs
transition
"
disabled={!$ae_loc.authenticated_access}>
<span class="fas fa-plus m-1"></span> Create New Meeting
</button>
{/if}
goto(
`/idaa/recovery_meetings/${results.event_id}`
);
}
})
.catch((error) => {
console.error('Error creating event:', error);
alert('Failed to create new event.');
});
}}
class="novi_btn btn-tertiary btn btn-sm preset-tonal-warning
preset-outlined-warning-200-800 hover:preset-filled-warning-200-800 text-xs transition"
disabled={!$ae_loc.authenticated_access}>
<span class="fas fa-plus m-1"></span> Create New Meeting
</button>
{/if}
{#if $ae_loc.edit_mode && $ae_loc.trusted_access}
<button
type="button"
onclick={() => {
if (!confirm('Download exported data Excel file?')) {
return false;
}
ae_promises.download__events_export =
core_func.download_export__obj_type({
api_cfg: $ae_api,
get_obj_type: 'event',
for_obj_type: 'account',
for_obj_id: $ae_loc.account_id,
exp_alt: 'idaa',
file_type: 'Excel',
return_file: true,
filename: `${$ae_loc.account_code}_IDAA_Recovery_Meetings_export_${ae_util.iso_datetime_formatter()}.xlsx`,
auto_download: true,
log_lvl: 1
});
}}
class="
novi_btn btn-tertiary
btn
btn-sm preset-tonal-warning
preset-outlined-warning-200-800 hover:preset-filled-warning-200-800 text-xs
transition
"
title={`Download meeting data for ${$ae_loc.account_name}`}>
{#await ae_promises.download__events_export}
<span class="fas fa-spinner fa-spin m-1"></span>
{:then}
<span class="fas fa-download m-1"></span>
{/await}
Export All Data
</button>
{/if}
</span>
</div>
<!-- Staff: export all meeting data to Excel (trusted + edit mode) -->
{#if $ae_loc.edit_mode && $ae_loc.trusted_access}
<button
type="button"
onclick={() => {
if (!confirm('Download exported data Excel file?')) {
return false;
}
ae_promises.download__events_export =
core_func.download_export__obj_type({
api_cfg: $ae_api,
get_obj_type: 'event',
for_obj_type: 'account',
for_obj_id: $ae_loc.account_id,
exp_alt: 'idaa',
file_type: 'Excel',
return_file: true,
filename: `${$ae_loc.account_code}_IDAA_Recovery_Meetings_export_${ae_util.iso_datetime_formatter()}.xlsx`,
auto_download: true,
log_lvl: 1
});
}}
class="novi_btn btn-tertiary btn btn-sm preset-tonal-warning
preset-outlined-warning-200-800 hover:preset-filled-warning-200-800 text-xs transition"
title={`Download meeting data for ${$ae_loc.account_name}`}>
{#await ae_promises.download__events_export}
<span class="fas fa-spinner fa-spin m-1"></span>
{:then}
<span class="fas fa-download m-1"></span>
{/await}
Export All Data
</button>
{/if}
</div>
</form>
</section>