diff --git a/TODO.md b/TODO.md index 1f1b3ff5..b1f0e5c1 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ This is a list of tasks to be completed before the next event/show/conference. ## Urgent Tasks (Feb 4, 2026) 1. **IDAA Module Verification:** - - [ ] **Archives:** Verify everything loads/plays; ensure Archives and Archive Content are editable; check linked file management. + - [x] **Archives:** Verify everything loads/plays; ensure Archives and Archive Content are editable; check linked file management. (Completed 2026-02-05) - [ ] **Bulletin Board (BB):** Verify Post/Comment create/edit; test email notifications for staff and original posters; verify anonymous toggle; test file attachments on Posts; optimize inline image display (collapse/expand). - [ ] **Recovery Meetings:** Verify all search filters; audit full editing workflow; verify special code/copy buttons for Zoom/Jitsi. diff --git a/src/lib/ae_events/ae_events__event.ts b/src/lib/ae_events/ae_events__event.ts index 90deb882..a311c54c 100644 --- a/src/lib/ae_events/ae_events__event.ts +++ b/src/lib/ae_events/ae_events__event.ts @@ -192,7 +192,7 @@ export async function load_ae_obj_li__event({ hidden = 'not_hidden', view = 'default', inc_session_li = false, - limit = 99, + limit = 9, offset = 0, order_by_li = { start_datetime: 'DESC' } as const, try_cache = true, @@ -307,15 +307,16 @@ export async function load_ae_obj_li__event({ } if (inc_session_li && ae_promises.load__event_obj_li) { - for (const event_obj of ae_promises.load__event_obj_li) { + const session_tasks = ae_promises.load__event_obj_li.map((event_obj: any) => { const current_event_id = event_obj.id || event_obj.event_id; - event_obj.event_session_obj_li = await load_ae_obj_li__event_session({ + return load_ae_obj_li__event_session({ api_cfg, for_obj_type: 'event', for_obj_id: current_event_id, log_lvl - }); - } + }).then((res) => (event_obj.event_session_obj_li = res)); + }); + await Promise.all(session_tasks); } return ae_promises.load__event_obj_li; @@ -450,6 +451,16 @@ export async function update_ae_obj__event({ } // Updated 2026-01-21 +/** + * Unified Search for Events (V3 API) + * + * STRATEGY: Hybrid Search/Filter + * 1. Server-side (V3 Search): Used for high-level filtering (Account, Type, String Search). + * This reduces the data payload significantly. + * 2. Client-side (Filter Layer): Used for complex logic that the backend Search API + * may not handle natively yet, such as inclusive 'OR' logic for Physical/Virtual + * meetings or specific person ID matching. + */ export async function search__event({ api_cfg, for_obj_type = 'account', @@ -491,17 +502,35 @@ export async function search__event({ let result_li: ae_Event[] | null = null; - if (qry_str && qry_str.trim().length > 0) { - // Option A: Active Text Search + // Use V3 Search if ANY filter is active to ensure we query the full database + const has_active_filters = (qry_str && qry_str.trim().length > 0) || + qry_physical === true || + qry_virtual === true || + (qry_type && qry_type !== 'all' && qry_type !== '') || + qry_person_id || + qry_conference !== null; + + if (has_active_filters) { + // Option A: Active Search (Server-side filtering) const search_query: any = { and: [] }; const params: key_val = {}; - // Use default_qry_str for searching as requested - // Using 'like' with wildcards and populating params['lk_qry'] to match working event_session logic - search_query.and.push({ field: 'default_qry_str', op: 'like', value: `%${qry_str.trim()}%` }); - params['lk_qry'] = { 'default_qry_str': qry_str.trim() }; + if (qry_str && qry_str.trim().length > 0) { + // Use default_qry_str for searching as requested + // Using 'like' with wildcards and populating params['lk_qry'] to match working event_session logic + search_query.and.push({ field: 'default_qry_str', op: 'like', value: `%${qry_str.trim()}%` }); + params['lk_qry'] = { 'default_qry_str': qry_str.trim() }; + } + + // NOTE: We do NOT push 'physical' and 'virtual' to the server-side query here. + // The V3 Search API uses AND logic, which would exclude meetings that are + // only physical or only virtual. We let the Client-side Filter handle this below. + + if (qry_type && qry_type !== 'all' && qry_type !== '') { + search_query.and.push({ field: 'type', op: 'eq', value: qry_type }); + } if (for_obj_id) { search_query.and.push({ field: `${for_obj_type}_id`, op: 'eq', value: for_obj_id }); @@ -529,7 +558,7 @@ export async function search__event({ log_lvl }); } else { - // Option B: List All (No text search active) + // Option B: List All (No filters active) // Fallback to standard list retrieval to ensure we get results when the search bar is empty. result_li = await api.get_ae_obj_li_v3({ api_cfg, @@ -572,7 +601,15 @@ export async function search__event({ }); } - // Client-side Filter Layer (Retained for complex OR logic and Person ID checks) + /** + * Client-side Filter Layer + * + * WHY: The V3 Search API defaults to 'AND' logic for its top-level filters. + * Some UI requirements (like showing meetings that are EITHER physical OR virtual) + * are more reliably handled here after the broad server-side fetch. + * This also ensures that complex person-id matching across multiple legacy fields + * remains consistent without requiring massive backend search indices. + */ const filtered_obj_li = processed_obj_li.filter((ev: any) => { // Handle conference filter if (qry_conference != null) { diff --git a/src/routes/idaa/(idaa)/archives/+page.svelte b/src/routes/idaa/(idaa)/archives/+page.svelte index 66eeb717..e324c28d 100644 --- a/src/routes/idaa/(idaa)/archives/+page.svelte +++ b/src/routes/idaa/(idaa)/archives/+page.svelte @@ -138,7 +138,7 @@ {#await lq__archive_obj_li} -
+
Loading archives...
@@ -159,7 +159,7 @@ {#if $lq__archive_obj_li && $lq__archive_obj_li?.length} {:else} -
+

No archives found.

Archives will appear here once created. diff --git a/src/routes/idaa/(idaa)/recovery_meetings/+page.svelte b/src/routes/idaa/(idaa)/recovery_meetings/+page.svelte index 264d0048..7628cac9 100644 --- a/src/routes/idaa/(idaa)/recovery_meetings/+page.svelte +++ b/src/routes/idaa/(idaa)/recovery_meetings/+page.svelte @@ -46,8 +46,12 @@ let last_search_id = 0; // Standardized Reactive Search Pattern (Aether UI V3) + // This effect manages the orchestration between UI state and data fetching. $effect(() => { // 1. Reactive Dependencies + const account_id = $ae_loc.account_id; + if (!account_id) return; // Wait for account context + // Track filters and the search version (trigger) const qry_params = { v: $idaa_loc.recovery_meetings.search_version, @@ -57,8 +61,7 @@ type: $idaa_loc.recovery_meetings.qry__type, limit: $idaa_loc.recovery_meetings.qry__limit, order: $idaa_loc.recovery_meetings.qry__order_by, - remote: $idaa_loc.recovery_meetings.qry__remote_first, - account: $ae_loc.account_id + remote: $idaa_loc.recovery_meetings.qry__remote_first }; // 2. Debounce Logic @@ -75,21 +78,28 @@ }; }); + /** + * SWR (Stale-While-Revalidate) Search Orchestrator + * + * GOAL: Render matching meetings in < 50ms, then update with perfect server data. + */ async function handle_search_refresh() { const current_search_id = ++last_search_id; const account_id = $ae_loc.account_id; const remote_first = $idaa_loc.recovery_meetings.qry__remote_first; + if (!account_id) return; + if (log_lvl) console.log(`[Search #${current_search_id}] Refreshing recovery meetings (remote_first=${remote_first}) for account: ${account_id}...`); $idaa_sess.recovery_meetings.qry__status = 'loading'; - // If remote first, clear immediately to show fresh state + // If 'Remote First' is toggled (Admin only), we clear results immediately to show fresh state. if (remote_first) { event_id_random_li = []; } - // Snapshot current params to ensure Fast Path matches revalidation + // Snapshot parameters to ensure the Fast Path and Revalidation are using the same criteria. const qry_str = ($idaa_loc.recovery_meetings.qry__fulltext_str ?? '').toLowerCase().trim(); const qry_physical = $idaa_loc.recovery_meetings.qry__physical; const qry_virtual = $idaa_loc.recovery_meetings.qry__virtual; @@ -97,55 +107,53 @@ let local_ids: string[] = []; - // 1. FAST PATH: Local IDB Search (SWR Pattern) - Skip if Remote First + // 1. FAST PATH: Local IDB Search + // We query Dexie first to show results from the 499-item cache pool instantly. if (!remote_first) { try { - if (account_id) { - let local_results = await db_events.event - .filter(ev => { - // Resilient account check: match either account_id or account_id_random - const acct_match = ev.account_id === account_id || ev.account_id_random === account_id; - if (!acct_match) return false; + let local_results = await db_events.event + .filter(ev => { + // Resilient account check + const acct_match = ev.account_id === account_id || ev.account_id_random === account_id; + if (!acct_match) return false; - if (qry_type && ev.type !== qry_type) return false; - if (qry_physical || qry_virtual) { - let match = false; - if (qry_physical && ev.physical) match = true; - if (qry_virtual && ev.virtual) match = true; - if (!match) return false; - } - if (qry_str) { - const name = (ev.name ?? '').toLowerCase(); - const desc = (ev.description ?? '').toLowerCase(); - const loc = (ev.location_text ?? '').toLowerCase(); - return name.includes(qry_str) || desc.includes(qry_str) || loc.includes(qry_str); - } - return true; - }) - .toArray(); - - // Sort local results - if ($idaa_loc.recovery_meetings.qry__order_by === 'name') { - local_results.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); - } else { - local_results.sort((a, b) => { - const dateA = a.updated_on ? new Date(a.updated_on).getTime() : 0; - const dateB = b.updated_on ? new Date(b.updated_on).getTime() : 0; - return dateB - dateA; - }); - } - - local_ids = local_results.map(e => e.id || e.event_id_random).filter(Boolean); - - // Update UI immediately with local results - if (current_search_id === last_search_id) { - if (log_lvl) console.log(`[Search #${current_search_id}] Fast Path complete. Found ${local_ids.length} items locally.`); - event_id_random_li = local_ids; - // If we found results locally, we can mark as done to stop spinning, - // revalidation will still finish in background - if (local_ids.length > 0) { - $idaa_sess.recovery_meetings.qry__status = 'done'; + if (qry_type && ev.type !== qry_type) return false; + if (qry_physical || qry_virtual) { + let match = false; + // Loose equality to handle 1, '1', true from DB + if (qry_physical && ev.physical == true) match = true; + if (qry_virtual && ev.virtual == true) match = true; + if (!match) return false; } + if (qry_str) { + const name = (ev.name ?? '').toLowerCase(); + const desc = (ev.description ?? '').toLowerCase(); + const loc = (ev.location_text ?? '').toLowerCase(); + return name.includes(qry_str) || desc.includes(qry_str) || loc.includes(qry_str); + } + return true; + }) + .toArray(); + + // Sort local results matching UI selection + if ($idaa_loc.recovery_meetings.qry__order_by === 'name') { + local_results.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + } else { + local_results.sort((a, b) => { + const dateA = a.updated_on ? new Date(a.updated_on).getTime() : 0; + const dateB = b.updated_on ? new Date(b.updated_on).getTime() : 0; + return dateB - dateA; + }); + } + + local_ids = local_results.map(e => String(e.id || e.event_id_random)).filter(Boolean); + + // Update UI immediately. This eliminates the "white page" during searching. + if (current_search_id === last_search_id) { + if (log_lvl) console.log(`[Search #${current_search_id}] Fast Path complete. Found ${local_ids.length} items locally.`); + event_id_random_li = local_ids; + if (local_ids.length > 0) { + $idaa_sess.recovery_meetings.qry__status = 'done'; } } } catch (e) { @@ -153,7 +161,8 @@ } } - // 2. REVALIDATE: Slow API Request + // 2. REVALIDATE: Server API Search + // This hits the full database, bypassing the "cache pool" limitation. try { const results = await events_func.search__event({ api_cfg: $ae_api, @@ -172,18 +181,51 @@ }); if (current_search_id === last_search_id) { - const api_results = results || []; - const api_ids = api_results.map((e: any) => e.id || e.event_id_random).filter(Boolean); + let api_results = results || []; - // If API returns 0 but local search found broad results, protect the UI - if (api_ids.length === 0 && local_ids.length > 0 && !remote_first && !qry_str) { - if (log_lvl) console.warn(`[Search #${current_search_id}] Revalidation returned 0. Preserving cache.`); + // SECONDARY FILTER: Ensure API results respect exact UI filters (handles backend broadness) + api_results = api_results.filter((ev: any) => { + if (qry_type && ev.type !== qry_type) return false; + if (qry_physical || qry_virtual) { + let match = false; + if (qry_physical && ev.physical == true) match = true; + if (qry_virtual && ev.virtual == true) match = true; + if (!match) return false; + } + if (qry_str && qry_str.length >= 3) { + const name = (ev.name ?? '').toLowerCase(); + const desc = (ev.description ?? '').toLowerCase(); + const loc = (ev.location_text ?? '').toLowerCase(); + if (!name.includes(qry_str) && !desc.includes(qry_str) && !loc.includes(qry_str)) return false; + } + return true; + }); + + const api_ids = api_results.map((e: any) => String(e.id || e.event_id_random)).filter(Boolean); + + // UI PROTECTION: Preserve results if API returns nothing (0 results) + // BUT only if it was a successful empty response, not a failure. + // ALSO: Only protect if qry_str was the primary change. + // If Type or Location changed, we want to see the 0. + const filter_changed = qry_type !== $idaa_sess.recovery_meetings.status_qry__last_type || + qry_physical !== $idaa_sess.recovery_meetings.status_qry__last_phys || + qry_virtual !== $idaa_sess.recovery_meetings.status_qry__last_virt; + + if (api_ids.length === 0 && local_ids.length > 0 && !remote_first && !filter_changed) { + if (log_lvl) console.warn(`[Search #${current_search_id}] API returned 0 matching results. Preserving local cache view.`); $idaa_sess.recovery_meetings.qry__status = 'done'; return; } $idaa_slct.event_obj_li = api_results; event_id_random_li = api_ids; + + // Snapshot last used filters for future protection checks + $idaa_sess.recovery_meetings.status_qry__last_request_str = qry_str; + $idaa_sess.recovery_meetings.status_qry__last_type = qry_type; + $idaa_sess.recovery_meetings.status_qry__last_phys = qry_physical; + $idaa_sess.recovery_meetings.status_qry__last_virt = qry_virtual; + $idaa_sess.recovery_meetings.qry__status = 'done'; if (log_lvl) console.log(`[Search #${current_search_id}] Revalidation Complete. Found ${api_ids.length} items.`); } @@ -191,9 +233,11 @@ if (current_search_id === last_search_id) { console.error('Revalidation failed:', error); $idaa_sess.recovery_meetings.qry__status = 'error'; - if (event_id_random_li.length === 0 && local_ids.length > 0) { - event_id_random_li = local_ids; - } + + // If the API failed (e.g. 400/500), we should NOT preserve results from a + // previous successful search as it's misleading. + // We clear the list so the 'No recovery meetings found' / Error message shows. + event_id_random_li = []; } } } diff --git a/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_li.svelte b/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_li.svelte index 0e19f448..0a617796 100644 --- a/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_li.svelte +++ b/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_li.svelte @@ -37,36 +37,44 @@ import { idaa_loc, idaa_sess, idaa_slct, idaa_trig } from '$lib/stores/ae_idaa_stores'; import MyClipboard from '$lib/app_components/e_app_clipboard.svelte'; - // Derived list of visible items (Refactored 2026-01-27) - // Ensures count matches exactly what is rendered to the user - let visible_event_obj_li = $derived((() => { - // Subscribe to the LiveQuery observable using $ prefix - const list = $lq__event_obj_li; - - if (!list || !Array.isArray(list)) { - if (log_lvl > 1) console.log('visible_event_obj_li: Waiting for data stream...'); - return []; - } - - const filtered = list.filter((item: any) => { - if (!item) return false; - - // ADMIN/TRUSTED: See everything - if ($ae_loc.trusted_access) return true; - - // PUBLIC: Filter hidden/disabled - // Safely handle null/undefined fields by assuming visible/enabled (permissive default) - const is_hidden = item.hide === true || item.hide === 1; - const is_disabled = item.enable === false || item.enable === 0; // Only block if explicitly false/0 - - return !is_hidden && !is_disabled; + // Derived list of visible items (Refactored 2026-02-05) + // + // WHY: The parent search logic fetches matching records into the local IndexedDB. + // This derived store performs the final client-side visibility layer to ensure + // that the results count matches exactly what is rendered to the user. + let visible_event_obj_li = $derived.by(() => { + // Subscribe to the LiveQuery observable using $ prefix + const list = $lq__event_obj_li; + + if (!list || !Array.isArray(list)) { + if (log_lvl > 1) console.log('visible_event_obj_li: Waiting for data stream...'); + return []; + } + + /** + * DUAL-LAYER VISIBILITY LOGIC + * 1. Public Logic: If an item is enabled and NOT hidden, everyone sees it. + * 2. Trusted Logic: If an item is disabled OR hidden, ONLY trusted users see it. + * + * NOTE: We use loose equality (==) because the database often returns + * numeric 1/0 or string '1'/'0' for these tinyint fields. + */ + const filtered = list.filter((item: any) => { + if (!item) return false; + + const is_hidden = item.hide == true; + const is_disabled = item.enable == false; + + if (!is_hidden && !is_disabled) return true; + + return ($ae_loc.trusted_access === true); + }); + + if (log_lvl) console.log(`visible_event_obj_li: Input=${list.length}, Output=${filtered.length} (trusted=${$ae_loc.trusted_access})`); + + // Final safety slice to respect the user's limit selection + return filtered.slice(0, $idaa_loc.recovery_meetings.qry__limit || 150); }); - - if (log_lvl) console.log(`visible_event_obj_li: Input=${list.length}, Output=${filtered.length} (trusted=${$ae_loc.trusted_access})`); - - return filtered.slice(0, $idaa_loc.recovery_meetings.qry__limit || 150); - })()); - if (browser) { if (log_lvl) { console.log(`link_to_type: ${link_to_type}; link_to_id: ${link_to_id}`); diff --git a/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_li_wrapper.svelte b/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_li_wrapper.svelte index 4d7d057f..cb82351c 100644 --- a/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_li_wrapper.svelte +++ b/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_li_wrapper.svelte @@ -39,9 +39,17 @@ let dq__where_type_id_val: string = `${link_to_type}_id`; let dq__where_eq_id_val: string = link_to_id; - // Stable LiveQuery Pattern (Aether UI V3) - // We wrap in $derived to ensure Svelte recreates the observable - // whenever the input IDs (props) change. + /** + * Stable LiveQuery Pattern (Aether UI V3) + * + * WHY: We wrap liveQuery in $derived to ensure that Svelte recreates the + * Dexie observable whenever the input props (event_id_random_li) change. + * + * TWO SCENARIOS: + * 1. Specific IDs: If event_id_random_li is provided (from the Search Orchestrator), + * we use bulkGet for high-performance targeted retrieval. + * 2. Fallback: If no IDs are provided, we perform a broad search for the account. + */ let lq__event_obj_li = $derived( liveQuery(async () => { const ids = event_id_random_li; @@ -51,6 +59,7 @@ if (ids.length > 0) { if (log_lvl) console.log(`Wrapper LQ: bulkGet ${ids.length} IDs`); const results = await db_events.event.bulkGet(ids); + // bulkGet returns undefined for missing keys; we filter those out. return results.filter(item => item !== undefined); } else { if (log_lvl) console.log('Wrapper LQ: Explicitly empty list'); diff --git a/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_qry.svelte b/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_qry.svelte index 4c60c358..6a168634 100644 --- a/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_qry.svelte +++ b/src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_qry.svelte @@ -37,6 +37,13 @@ let ae_promises: key_val = $state({}); + /** + * Session Persistent Search (Refactored 2026-01-20) + * + * WHY: If the user has 'save_search_text' enabled in their local profile, + * we map the long-term saved strings into the current session store on mount. + * This ensures the search bar is pre-populated with their last known query. + */ if ( $idaa_loc.recovery_meetings?.save_search_text && $idaa_loc.recovery_meetings?.saved_search__session @@ -53,6 +60,14 @@ } // *** Functions and Logic + /** + * Reactive Search Trigger + * + * WHY: Instead of calling handle_search_refresh() directly (which can be messy with + * Svelte 5's fine-grained reactivity), we increment a 'version' number. + * The $effect in the parent +page.svelte tracks this version and handles the + * debounced search cycle automatically. + */ function handle_search_trigger() { if ($idaa_loc.recovery_meetings.search_version === undefined) { $idaa_loc.recovery_meetings.search_version = 0;