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>
This commit is contained in:
Scott Idem
2026-05-18 17:00:51 -04:00
parent 730fb19d60
commit 3a81887c56
2 changed files with 125 additions and 13 deletions

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)
@@ -441,24 +456,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