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>
This commit is contained in:
Scott Idem
2026-05-18 16:09:05 -04:00
parent 2d552b36fd
commit 12429ccf2e
3 changed files with 128 additions and 6 deletions

View File

@@ -98,7 +98,12 @@ 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
}
};

View File

@@ -37,8 +37,53 @@ import {
idaa_slct,
idaa_trig
} from '$lib/stores/ae_idaa_stores';
import { db_events } from '$lib/ae_events/db_events';
import { events_func } from '$lib/ae_events/ae_events_functions';
import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
// Tracks event IDs currently being toggled to prevent double-clicks
let favorites_in_progress = $state<Set<string>>(new Set());
function check_fav(event_obj: Record<string, unknown>): boolean {
const my_uuid = $idaa_loc.novi_uuid;
if (!my_uuid) return false;
const mod = event_obj.mod_meetings_json as Record<string, unknown> | null | undefined;
return Array.isArray(mod?.favorite) && (mod.favorite as string[]).includes(my_uuid);
}
async function toggle_favorite(event_obj: Record<string, unknown>) {
const my_uuid = $idaa_loc.novi_uuid;
const obj_id = event_obj.id as string; // Dexie primary key (same value as event_id)
const event_id = event_obj.event_id as string; // V3 API UUID
if (!my_uuid || !event_id || favorites_in_progress.has(event_id)) return;
favorites_in_progress.add(event_id);
const current_json = (event_obj.mod_meetings_json as Record<string, unknown>) ?? {};
const current_list: string[] = Array.isArray(current_json.favorite) ? [...(current_json.favorite as string[])] : [];
const already_fav = current_list.includes(my_uuid);
const new_list = already_fav ? current_list.filter((u) => u !== my_uuid) : [...current_list, my_uuid];
const new_json = { ...current_json, favorite: new_list };
// Optimistic IDB update — liveQuery reactively re-renders the card instantly
await db_events.event.update(obj_id, { mod_meetings_json: new_json });
try {
await events_func.update_ae_obj__event({
api_cfg: $ae_api,
event_id,
data_kv: { mod_meetings_json: new_json },
try_cache: true
});
} catch (err) {
console.warn('[favorites] API persist failed, rolling back:', err);
await db_events.event.update(obj_id, { mod_meetings_json: current_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 +123,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 +193,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 +644,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

@@ -411,6 +411,29 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
</span>
<span class="flex flex-row items-center justify-around gap-1">
{#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}
{#if ($ae_loc.trusted_access && $ae_loc.edit_mode) || $idaa_loc.novi_uuid}
<button
type="button"