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:
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user