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__fulltext_str: null,
|
||||||
qry__physical: null,
|
qry__physical: null,
|
||||||
qry__type: 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_slct,
|
||||||
idaa_trig
|
idaa_trig
|
||||||
} from '$lib/stores/ae_idaa_stores';
|
} 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';
|
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)
|
// Derived list of visible items (Refactored 2026-02-05)
|
||||||
//
|
//
|
||||||
// WHY: The parent search logic fetches matching records into the local IndexedDB.
|
// 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})`
|
`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
|
// 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(() => {
|
$effect(() => {
|
||||||
if (log_lvl) {
|
if (log_lvl) {
|
||||||
@@ -129,10 +193,33 @@ $effect(() => {
|
|||||||
flex-row items-center justify-between gap-2 text-2xl
|
flex-row items-center justify-between gap-2 text-2xl
|
||||||
font-bold
|
font-bold
|
||||||
">
|
">
|
||||||
<span
|
<span class="flex flex-row items-center gap-2">
|
||||||
class="fas fa-calendar-day text-neutral-800/80"
|
<span
|
||||||
></span>
|
class="fas fa-calendar-day text-neutral-800/80"
|
||||||
{idaa_event_obj?.name ?? 'Recovery Meeting'}
|
></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>
|
</h3>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -557,6 +644,13 @@ $effect(() => {
|
|||||||
<span class="fas fa-spinner fa-spin m-1"></span>
|
<span class="fas fa-spinner fa-spin m-1"></span>
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="ae_highlight ae_padding_md ae_row ae_flex_justify_center">
|
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>
|
||||||
|
|
||||||
<span class="flex flex-row items-center justify-around gap-1">
|
<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}
|
{#if ($ae_loc.trusted_access && $ae_loc.edit_mode) || $idaa_loc.novi_uuid}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user