Tighten Jitsi report exclusions

Use Jitsi url_params.uuid for exclusion where available, preserve url_params in cached activity logs, and add the temporary staff-name fallback behind the same edit-mode toggle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Scott Idem
2026-05-06 11:47:43 -04:00
parent 3ae9d0a884
commit 7497bfb9f8
5 changed files with 408 additions and 207 deletions

View File

@@ -490,6 +490,13 @@ Both modes use the same filtered data set — switching views does not reset fil
A "Reset Filters" button appears whenever any filter is non-default. A "Reset Filters" button appears whenever any filter is non-default.
In edit mode, two extra toggles appear:
- **Show excluded IDs** — temporarily include the UUIDs listed in `jitsi_exclude_uuids`
- **Show all meetings** — temporarily ignore `jitsi_known_meetings`
An "Active Exclusions" panel below the filter bar shows the currently applied Novi UUID exclusions
and known meeting-name whitelist values. Each list is collapsible so the page stays compact.
### Staff / Meeting Filtering ### Staff / Meeting Filtering
**Problem:** Staff/test accounts and one-off test rooms distort the reports. **Problem:** Staff/test accounts and one-off test rooms distort the reports.
@@ -505,12 +512,16 @@ A "Reset Filters" button appears whenever any filter is non-default.
**How it works:** **How it works:**
1. The page reads `$ae_loc.site_cfg_json?.jitsi_exclude_uuids` and excludes matching participants by Novi UUID. 1. The page reads `$ae_loc.site_cfg_json?.jitsi_exclude_uuids` and excludes matching participants by Novi UUID.
The UUID comes from the Jitsi log `url_params.uuid` field. `g_uuid` is the meeting/group UUID and is not used here.
2. If a participant record does not include a UUID in the activity log, it is left visible; UUIDs are used whenever available. 2. If a participant record does not include a UUID in the activity log, it is left visible; UUIDs are used whenever available.
3. `real_participant_count = real_participants.length` drives filters, exports, and the per-meeting attendee count. 3. `real_participant_count = real_participants.length` drives filters, exports, and the per-meeting attendee count.
4. Room-level unique participant counts are computed from Novi UUIDs when present, with display-name fallback only for UUID-less records. 4. Room-level unique participant counts are computed from Novi UUIDs when present, with display-name fallback only for UUID-less records.
5. If `$ae_loc.site_cfg_json?.jitsi_known_meetings` is non-empty, only meetings whose `room_name` matches one of the listed names are shown. 5. If `$ae_loc.site_cfg_json?.jitsi_known_meetings` is non-empty, only meetings whose `room_name` matches one of the listed names are shown.
6. The Room Name filter is only shown when global edit mode is enabled. 6. The Room Name filter is only shown when global edit mode is enabled.
**Temporary stopgap:** the report also hides these staff display names through the same UUID-exclusion toggle until the long-term logging fix lands:
`Scott I.`, `Brie P.`, `Michelle V.`
**Note:** matching is case-insensitive on the stored `room_name` / meeting name. **Note:** matching is case-insensitive on the stored `room_name` / meeting name.
### Summary Stats ### Summary Stats
@@ -524,6 +535,12 @@ Shown above the meeting list when data is loaded. Stats reflect the **filtered +
In grouped view, each room header also shows its own subtotals (meeting count, unique participants by Novi UUID when available). In grouped view, each room header also shows its own subtotals (meeting count, unique participants by Novi UUID when available).
### Caching / Load Behavior
The page now reads cached `activity_log` rows from IndexedDB first, renders that result immediately,
then refreshes from the API in the background. That keeps the report usable even when the network
round-trip is slow.
### Jitsi URL Builder ### Jitsi URL Builder
Collapsible panel, visible to `trusted_access` users only. Generates properly-formatted Jitsi meeting URLs for IDAA rooms. Component: `ae_idaa_comp__jitsi_url_builder.svelte`. Collapsible panel, visible to `trusted_access` users only. Generates properly-formatted Jitsi meeting URLs for IDAA rooms. Component: `ae_idaa_comp__jitsi_url_builder.svelte`.

View File

@@ -270,6 +270,9 @@ export const properties_to_save = [
'user_id', 'user_id',
'user_id_random', 'user_id_random',
'external_client_id', 'external_client_id',
'url_root',
'url_full_path',
'url_params',
'source', 'source',
'object_type', 'object_type',
'object_id', 'object_id',

View File

@@ -7,6 +7,28 @@ import {
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie'; import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
import { db_core } from '$lib/ae_core/db_core'; import { db_core } from '$lib/ae_core/db_core';
interface MeetingEvent {
timestamp: string;
action: string;
details: { full_name?: string };
}
interface MeetingParticipant {
displayName: string;
role: string;
novi_uuid?: string;
}
interface MeetingReport {
meeting_id: string;
room_name: string;
start_time: string;
final_duration: string;
final_participant_count: number;
final_participants: MeetingParticipant[];
events: MeetingEvent[];
}
// MariaDB TEXT columns come back as JSON strings from the API — parse safely. // MariaDB TEXT columns come back as JSON strings from the API — parse safely.
function safe_parse_meta(raw: unknown): Record<string, unknown> { function safe_parse_meta(raw: unknown): Record<string, unknown> {
if (!raw) return {}; if (!raw) return {};
@@ -32,6 +54,16 @@ function normalize_uuid(value: unknown): string {
return normalize_text(value).toLowerCase(); return normalize_text(value).toLowerCase();
} }
function extract_uuid_from_url_params(raw: unknown): string {
if (typeof raw !== 'string' || !raw.trim()) return '';
try {
const params = new URLSearchParams(raw);
return normalize_uuid(params.get('uuid'));
} catch {
return '';
}
}
function extract_participant_uuid(source: Record<string, unknown>): string { function extract_participant_uuid(source: Record<string, unknown>): string {
const nested_user = source.user as Record<string, unknown> | undefined; const nested_user = source.user as Record<string, unknown> | undefined;
const nested_context = source.context as Record<string, unknown> | undefined; const nested_context = source.context as Record<string, unknown> | undefined;
@@ -45,12 +77,8 @@ function extract_participant_uuid(source: Record<string, unknown>): string {
source.novi_customer_uid, source.novi_customer_uid,
nested_user?.novi_uuid, nested_user?.novi_uuid,
nested_user?.uuid, nested_user?.uuid,
nested_user?.id,
nested_context_user?.novi_uuid, nested_context_user?.novi_uuid,
nested_context_user?.uuid, nested_context_user?.uuid
nested_context_user?.id,
source.user_id,
source.id
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
@@ -73,6 +101,236 @@ function extract_participant_uuid(source: Record<string, unknown>): string {
* @param log_lvl The logging level. * @param log_lvl The logging level.
* @returns A structured array of meeting report objects. * @returns A structured array of meeting report objects.
*/ */
function build_jitsi_report_from_logs(
flat_log_list: any[],
log_lvl = 0
): MeetingReport[] {
// Participants come from two sources — both are needed for a complete list:
// 1. jitsi_meeting_participant_joined events: meta.full_name (all who ever joined)
// 2. jitsi_meeting_init / stats / end snapshots: meta.participants[].displayName+role
// Source 2 has role info; source 1 may catch participants who left before the snapshot.
// We merge both into a Map<displayName, participant> per meeting, keeping the
// stable display-name key used by the existing report while preserving Novi UUIDs
// when the log payload includes them.
//
// Duration: take the MAX across all init/stats/end events — periodic stats may
// have a higher value than the final init summary in some Jitsi configurations.
const meetings = new Map<string, MeetingReport>();
const participant_maps = new Map<string, Map<string, MeetingParticipant>>();
const max_duration_secs = new Map<string, number>();
for (const log of flat_log_list) {
const meeting_id = log.external_client_id;
if (!meeting_id) continue;
if (!log.name?.startsWith('jitsi_')) continue;
if (!meetings.has(meeting_id)) {
meetings.set(meeting_id, {
meeting_id,
room_name: 'Unknown',
start_time: log.created_on,
final_duration: '00:00:00',
final_participants: [],
final_participant_count: 0,
events: []
});
participant_maps.set(meeting_id, new Map());
max_duration_secs.set(meeting_id, 0);
}
const meeting_report = meetings.get(meeting_id)!;
const p_map = participant_maps.get(meeting_id)!;
const meta = safe_parse_meta(log.meta_json);
const log_novi_uuid = extract_uuid_from_url_params(log.url_params);
if (log.action === 'jitsi_meeting_init') {
// Strip "Event in room: " prefix Jitsi sometimes prepends to the description
meeting_report.room_name =
(log.description ?? '').replace(/^Event in room:\s*/i, '').trim() ||
'Unknown';
meeting_report.start_time = log.created_on;
}
// Parse duration from init, stats, or end — keep the maximum seen
if (
log.action === 'jitsi_meeting_init' ||
log.action === 'jitsi_meeting_stats' ||
log.action === 'jitsi_meeting_end'
) {
const dur_str = ((meta.duration ?? meta.final_duration) as string) || '';
if (dur_str) {
const secs = parse_duration_seconds(dur_str);
if (secs > (max_duration_secs.get(meeting_id) ?? 0)) {
max_duration_secs.set(meeting_id, secs);
meeting_report.final_duration = dur_str;
}
}
// Merge snapshot participant list (has role info — preferred source)
const snapshot = meta.participants as
| Array<Record<string, unknown>>
| undefined;
if (Array.isArray(snapshot)) {
for (const p of snapshot) {
const display_name = normalize_text(p.displayName);
if (!display_name) continue;
const role =
typeof p.role === 'string' ? p.role : 'participant';
const novi_uuid =
extract_participant_uuid(p) ||
(p.role === 'moderator'
? normalize_uuid(meta.moderator_novi_uuid)
: '');
const existing_participant = p_map.get(display_name);
if (!existing_participant) {
p_map.set(display_name, {
displayName: display_name,
role,
...(novi_uuid ? { novi_uuid } : {})
});
continue;
}
if (
existing_participant.role !== 'moderator' &&
role === 'moderator'
) {
existing_participant.role = 'moderator';
}
if (!existing_participant.novi_uuid && novi_uuid) {
existing_participant.novi_uuid = novi_uuid;
}
}
}
}
// Collect participants from join events (may catch people who left before the snapshot)
if (log.action === 'jitsi_meeting_participant_joined') {
const display_name = normalize_text(meta.full_name);
if (display_name) {
const role =
typeof meta.role === 'string' ? meta.role : 'participant';
const novi_uuid = log_novi_uuid || extract_participant_uuid(meta);
const existing_participant = p_map.get(display_name);
if (!existing_participant) {
p_map.set(display_name, {
displayName: display_name,
role,
...(novi_uuid ? { novi_uuid } : {})
});
} else {
if (
existing_participant.role !== 'moderator' &&
role === 'moderator'
) {
existing_participant.role = 'moderator';
}
if (!existing_participant.novi_uuid && novi_uuid) {
existing_participant.novi_uuid = novi_uuid;
}
}
}
}
// Discrete events for the timeline (all non-init actions)
if (log.action !== 'jitsi_meeting_init') {
meeting_report.events.push({
timestamp: log.created_on,
action: log.action,
details: {
full_name: (meta.full_name as string) ?? undefined
}
});
}
}
// Compile final participant lists from the deduplicated maps
for (const [meeting_id, p_map] of participant_maps) {
const meeting_report = meetings.get(meeting_id);
if (!meeting_report) continue;
if (p_map.size > 0) {
// Sort: moderators first, then alphabetically
meeting_report.final_participants = Array.from(p_map.values()).sort(
(a: MeetingParticipant, b: MeetingParticipant) => {
if (a.role === 'moderator' && b.role !== 'moderator') return -1;
if (a.role !== 'moderator' && b.role === 'moderator') return 1;
return a.displayName.localeCompare(b.displayName);
}
);
meeting_report.final_participant_count = p_map.size;
}
}
// Sort events within each meeting chronologically
for (const report of meetings.values()) {
report.events.sort(
(a: MeetingEvent, b: MeetingEvent) =>
new Date(a.timestamp).getTime() -
new Date(b.timestamp).getTime()
);
}
const final_report = Array.from(meetings.values());
final_report.sort(
(a, b) =>
new Date(b.start_time).getTime() - new Date(a.start_time).getTime()
);
if (log_lvl) console.log('Final Jitsi report:', final_report);
return final_report;
}
export async function load_jitsi_report_from_cache({
account_id,
limit = 500,
log_lvl = 0
}: {
account_id: string;
limit?: number;
log_lvl?: number;
}): Promise<MeetingReport[] | null> {
try {
const cached_logs = await db_core.activity_log
.where('account_id_random')
.equals(account_id)
.and(
(log) =>
log.name === 'jitsi_meeting_event' ||
log.name === 'jitsi_meeting_stats'
)
.limit(limit)
.toArray();
if (cached_logs.length === 0) return null;
if (
cached_logs.some(
(log) => typeof log.url_params !== 'string' || !log.url_params
)
) {
if (log_lvl) {
console.log(
'Jitsi report cache is missing url_params; using API refresh for accurate UUID filtering.'
);
}
return null;
}
if (log_lvl) {
console.log(
`Jitsi report cache hit: ${cached_logs.length} activity_log rows`
);
}
return build_jitsi_report_from_logs(cached_logs, log_lvl);
} catch (err) {
if (log_lvl) console.warn('Jitsi report cache read failed.', err);
return null;
}
}
export async function qry__jitsi_report({ export async function qry__jitsi_report({
api_cfg, api_cfg,
account_id, account_id,
@@ -149,182 +407,7 @@ export async function qry__jitsi_report({
}); });
} }
// Step 2: Process the flat list into a structured report. return build_jitsi_report_from_logs(flat_log_list, log_lvl);
//
// Participants come from two sources — both are needed for a complete list:
// 1. jitsi_meeting_participant_joined events: meta.full_name (all who ever joined)
// 2. jitsi_meeting_init / stats / end snapshots: meta.participants[].displayName+role
// Source 2 has role info; source 1 may catch participants who left before the snapshot.
// We merge both into a Map<displayName, participant> per meeting, keeping the
// stable display-name key used by the existing report while preserving Novi UUIDs
// when the log payload includes them.
//
// Duration: take the MAX across all init/stats/end events — periodic stats may
// have a higher value than the final init summary in some Jitsi configurations.
const meetings = new Map<string, any>();
// Per-meeting participant map: displayName → participant record
const participant_maps = new Map<string, Map<string, any>>();
// Per-meeting max observed duration in seconds
const max_duration_secs = new Map<string, number>();
for (const log of flat_log_list) {
const meeting_id = log.external_client_id;
if (!meeting_id) continue;
if (!log.name?.startsWith('jitsi_')) continue;
if (!meetings.has(meeting_id)) {
meetings.set(meeting_id, {
meeting_id,
room_name: 'Unknown',
start_time: log.created_on,
final_duration: '00:00:00',
final_participants: [],
final_participant_count: 0,
events: []
});
participant_maps.set(meeting_id, new Map());
max_duration_secs.set(meeting_id, 0);
}
const meeting_report = meetings.get(meeting_id);
const p_map = participant_maps.get(meeting_id)!;
const meta = safe_parse_meta(log.meta_json);
if (log.action === 'jitsi_meeting_init') {
// Strip "Event in room: " prefix Jitsi sometimes prepends to the description
meeting_report.room_name =
(log.description ?? '').replace(/^Event in room:\s*/i, '').trim() ||
'Unknown';
meeting_report.start_time = log.created_on;
}
// Parse duration from init, stats, or end — keep the maximum seen
if (
log.action === 'jitsi_meeting_init' ||
log.action === 'jitsi_meeting_stats' ||
log.action === 'jitsi_meeting_end'
) {
const dur_str = ((meta.duration ?? meta.final_duration) as string) || '';
if (dur_str) {
const secs = parse_duration_seconds(dur_str);
if (secs > (max_duration_secs.get(meeting_id) ?? 0)) {
max_duration_secs.set(meeting_id, secs);
meeting_report.final_duration = dur_str;
}
}
// Merge snapshot participant list (has role info — preferred source)
const snapshot = meta.participants as
| Array<Record<string, unknown>>
| undefined;
if (Array.isArray(snapshot)) {
for (const p of snapshot) {
const display_name = normalize_text(p.displayName);
if (!display_name) continue;
const role =
typeof p.role === 'string' ? p.role : 'participant';
const novi_uuid = extract_participant_uuid(p);
const existing_participant = p_map.get(display_name);
if (!existing_participant) {
p_map.set(display_name, {
displayName: display_name,
role,
...(novi_uuid ? { novi_uuid } : {})
});
continue;
}
if (
existing_participant.role !== 'moderator' &&
role === 'moderator'
) {
existing_participant.role = 'moderator';
}
if (!existing_participant.novi_uuid && novi_uuid) {
existing_participant.novi_uuid = novi_uuid;
}
}
}
}
// Collect participants from join events (may catch people who left before the snapshot)
if (log.action === 'jitsi_meeting_participant_joined') {
const display_name = normalize_text(meta.full_name);
if (display_name) {
const role =
typeof meta.role === 'string' ? meta.role : 'participant';
const novi_uuid = extract_participant_uuid(meta);
const existing_participant = p_map.get(display_name);
if (!existing_participant) {
p_map.set(display_name, {
displayName: display_name,
role,
...(novi_uuid ? { novi_uuid } : {})
});
} else {
if (
existing_participant.role !== 'moderator' &&
role === 'moderator'
) {
existing_participant.role = 'moderator';
}
if (!existing_participant.novi_uuid && novi_uuid) {
existing_participant.novi_uuid = novi_uuid;
}
}
}
}
// Discrete events for the timeline (all non-init actions)
if (log.action !== 'jitsi_meeting_init') {
meeting_report.events.push({
timestamp: log.created_on,
action: log.action,
details: {
full_name: (meta.full_name as string) ?? undefined
}
});
}
}
// Compile final participant lists from the deduplicated maps
for (const [meeting_id, p_map] of participant_maps) {
const meeting_report = meetings.get(meeting_id);
if (!meeting_report) continue;
if (p_map.size > 0) {
// Sort: moderators first, then alphabetically
meeting_report.final_participants = Array.from(p_map.values())
.sort((a: any, b: any) => {
if (a.role === 'moderator' && b.role !== 'moderator') return -1;
if (a.role !== 'moderator' && b.role === 'moderator') return 1;
return a.displayName.localeCompare(b.displayName);
});
meeting_report.final_participant_count = p_map.size;
}
}
// Sort events within each meeting chronologically
for (const report of meetings.values()) {
report.events.sort(
(a: any, b: any) =>
new Date(a.timestamp).getTime() -
new Date(b.timestamp).getTime()
);
}
const final_report = Array.from(meetings.values());
final_report.sort(
(a, b) =>
new Date(b.start_time).getTime() - new Date(a.start_time).getTime()
);
if (log_lvl) console.log('Final Jitsi report:', final_report);
return final_report;
} }
export const load_jitsi_report = qry__jitsi_report; export const load_jitsi_report = qry__jitsi_report;

View File

@@ -415,6 +415,9 @@ export interface ae_ActivityLog extends ae_BaseObj {
external_client_id?: string; external_client_id?: string;
source?: string; source?: string;
url_root?: string;
url_full_path?: string;
url_params?: string;
object_type?: string; object_type?: string;
object_id_random?: string; object_id_random?: string;

View File

@@ -1,7 +1,10 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
import { load_jitsi_report } from '$lib/ae_reports/reports_functions'; import {
load_jitsi_report,
load_jitsi_report_from_cache
} from '$lib/ae_reports/reports_functions';
import { ae_api, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { idaa_loc } from '$lib/stores/ae_idaa_stores'; import { idaa_loc } from '$lib/stores/ae_idaa_stores';
import JitsiUrlBuilder from './ae_idaa_comp__jitsi_url_builder.svelte'; import JitsiUrlBuilder from './ae_idaa_comp__jitsi_url_builder.svelte';
@@ -92,20 +95,42 @@ $effect(() => {
meetings_error = null; meetings_error = null;
untrack(() => { untrack(() => {
void load_jitsi_report({ void (async () => {
api_cfg, const cached = await load_jitsi_report_from_cache({
account_id, account_id,
log_lvl: 1 log_lvl: 1
})
.then((m: MeetingReport[]) => {
meetings_all = m ?? [];
meetings_loading = false;
})
.catch((err: unknown) => {
meetings_error =
err instanceof Error ? err.message : String(err);
meetings_loading = false;
}); });
if (cached && cached.length > 0) {
meetings_all = cached;
meetings_loading = false;
void load_jitsi_report({
api_cfg,
account_id,
log_lvl: 1
})
.then((m: MeetingReport[]) => {
meetings_all = m ?? [];
})
.catch((err: unknown) => {
console.warn('Jitsi report background refresh failed.', err);
});
return;
}
try {
const fresh = await load_jitsi_report({
api_cfg,
account_id,
log_lvl: 1
});
meetings_all = fresh ?? [];
} catch (err: unknown) {
meetings_error = err instanceof Error ? err.message : String(err);
} finally {
meetings_loading = false;
}
})();
}); });
}); });
@@ -118,6 +143,7 @@ let exclude_uuids = $derived<string[]>(
?.jitsi_exclude_uuids ?.jitsi_exclude_uuids
).map((uuid) => uuid.toLowerCase()) ).map((uuid) => uuid.toLowerCase())
); );
const temp_excluded_names = ['Scott I.', 'Brie P.', 'Michelle V.'];
let exclude_names = $derived<string[]>( let exclude_names = $derived<string[]>(
normalize_list( normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>) ($ae_loc.site_cfg_json as Record<string, unknown>)
@@ -132,7 +158,12 @@ let known_meetings = $derived<string[]>(
); );
let exclude_uuid_set = $derived(new Set(exclude_uuids)); let exclude_uuid_set = $derived(new Set(exclude_uuids));
let exclude_name_set = $derived(new Set(exclude_names)); let exclude_name_set = $derived(new Set(exclude_names));
let temp_excluded_name_set = $derived(
new Set(temp_excluded_names.map((name) => name.toLowerCase()))
);
let known_meeting_set = $derived(new Set(known_meetings)); let known_meeting_set = $derived(new Set(known_meetings));
let show_excluded_uuids = $state(false);
let show_all_meetings = $state(false);
// Apply exclusion to every meeting — produces the "real" participant list // Apply exclusion to every meeting — produces the "real" participant list
// used by all downstream filters, stats, and display. // used by all downstream filters, stats, and display.
@@ -141,13 +172,17 @@ let meetings_enriched = $derived<MeetingReportEnriched[]>(
// final_participants can be null/undefined if meta_json.participants was null in the API response // final_participants can be null/undefined if meta_json.participants was null in the API response
const all_participants: MeetingParticipant[] = m.final_participants ?? []; const all_participants: MeetingParticipant[] = m.final_participants ?? [];
const real_participants = all_participants.filter((p) => { const real_participants = all_participants.filter((p) => {
if (show_excluded_uuids) return true;
const participant_uuid = normalize_uuid(p.novi_uuid); const participant_uuid = normalize_uuid(p.novi_uuid);
if (participant_uuid && exclude_uuid_set.has(participant_uuid)) { if (participant_uuid && exclude_uuid_set.has(participant_uuid)) {
return false; return false;
} }
const participant_name = normalize_meeting_name(p.displayName); const participant_name = normalize_meeting_name(p.displayName);
if (exclude_name_set.has(participant_name)) { if (
exclude_name_set.has(participant_name) ||
temp_excluded_name_set.has(participant_name)
) {
return false; return false;
} }
@@ -174,7 +209,9 @@ let filter_date_to = $state(default_filter_date_to);
let filters_are_modified = $derived( let filters_are_modified = $derived(
filter_real_only || filter_real_only ||
filter_min_participants !== 0 || filter_min_participants !== 0 ||
filter_room_name !== '' || (filter_room_name !== '' && $ae_loc.edit_mode) ||
show_excluded_uuids ||
show_all_meetings ||
filter_date_from !== default_filter_date_from || filter_date_from !== default_filter_date_from ||
filter_date_to !== default_filter_date_to filter_date_to !== default_filter_date_to
); );
@@ -183,6 +220,8 @@ function reset_filters() {
filter_real_only = false; filter_real_only = false;
filter_min_participants = 0; filter_min_participants = 0;
filter_room_name = ''; filter_room_name = '';
show_excluded_uuids = false;
show_all_meetings = false;
filter_date_from = default_filter_date_from; filter_date_from = default_filter_date_from;
filter_date_to = default_filter_date_to; filter_date_to = default_filter_date_to;
} }
@@ -214,7 +253,7 @@ function compute_end_time(start_time: string, duration: string): string {
// All counts and thresholds use real_participant_count (post-exclusion). // All counts and thresholds use real_participant_count (post-exclusion).
let meetings_filtered = $derived.by(() => { let meetings_filtered = $derived.by(() => {
return meetings_enriched.filter((m) => { return meetings_enriched.filter((m) => {
if (known_meeting_set.size > 0) { if (!show_all_meetings && known_meeting_set.size > 0) {
if (!known_meeting_set.has(normalize_meeting_name(m.room_name))) { if (!known_meeting_set.has(normalize_meeting_name(m.room_name))) {
return false; return false;
} }
@@ -224,11 +263,14 @@ let meetings_filtered = $derived.by(() => {
if (m.real_participant_count < 2 && dur_secs <= 300) return false; if (m.real_participant_count < 2 && dur_secs <= 300) return false;
} }
if (m.real_participant_count < filter_min_participants) return false; if (m.real_participant_count < filter_min_participants) return false;
if ( if ($ae_loc.edit_mode && filter_room_name) {
filter_room_name && if (
!m.room_name?.toLowerCase().includes(filter_room_name.toLowerCase()) !m.room_name
) ?.toLowerCase()
return false; .includes(filter_room_name.toLowerCase())
)
return false;
}
if (filter_date_from) { if (filter_date_from) {
if (Date.parse(m.start_time) < Date.parse(filter_date_from)) if (Date.parse(m.start_time) < Date.parse(filter_date_from))
return false; return false;
@@ -475,6 +517,25 @@ function export_json() {
>Real meetings only</span> >Real meetings only</span>
</label> </label>
<div class="bg-surface-200-800 hidden h-8 w-px self-end sm:block"></div> <div class="bg-surface-200-800 hidden h-8 w-px self-end sm:block"></div>
{#if $ae_loc.edit_mode}
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
<input
type="checkbox"
bind:checked={show_excluded_uuids}
class="checkbox checkbox-sm" />
<span class="text-sm font-medium whitespace-nowrap"
>Show excluded IDs</span>
</label>
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
<input
type="checkbox"
bind:checked={show_all_meetings}
class="checkbox checkbox-sm" />
<span class="text-sm font-medium whitespace-nowrap"
>Show all meetings</span>
</label>
<div class="bg-surface-200-800 hidden h-8 w-px self-end sm:block"></div>
{/if}
<div> <div>
<label <label
for="filter_min_p" for="filter_min_p"
@@ -488,8 +549,8 @@ function export_json() {
bind:value={filter_min_participants} bind:value={filter_min_participants}
class="border-surface-200-800 bg-surface-50-950 w-20 rounded border px-2 py-1" /> class="border-surface-200-800 bg-surface-50-950 w-20 rounded border px-2 py-1" />
</div> </div>
<div> {#if $ae_loc.edit_mode}
{#if $ae_loc.edit_mode} <div>
<label <label
for="filter_room" for="filter_room"
class="mb-1 block text-xs tracking-wide uppercase opacity-40"> class="mb-1 block text-xs tracking-wide uppercase opacity-40">
@@ -501,8 +562,8 @@ function export_json() {
placeholder="Search rooms..." placeholder="Search rooms..."
bind:value={filter_room_name} bind:value={filter_room_name}
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" /> class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
{/if} </div>
</div> {/if}
<div> <div>
<label <label
for="filter_date_from" for="filter_date_from"
@@ -539,6 +600,40 @@ function export_json() {
{/if} {/if}
</div> </div>
{#if exclude_uuids.length > 0 || known_meetings.length > 0}
<div class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-3">
<div class="mb-2 text-xs tracking-wide uppercase opacity-50">
Active Exclusions
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<details class="bg-surface-50-950 rounded-lg border border-dashed border-opacity-30 p-2">
<summary class="cursor-pointer list-none font-semibold">
Excluded Novi UUIDs ({exclude_uuids.length})
</summary>
<div class="mt-2 space-y-1 text-xs font-mono">
{#each exclude_uuids as uuid (uuid)}
<div class="bg-surface-200-800 rounded px-2 py-1">
{uuid}
</div>
{/each}
</div>
</details>
<details class="bg-surface-50-950 rounded-lg border border-dashed border-opacity-30 p-2">
<summary class="cursor-pointer list-none font-semibold">
Known Meeting Names ({known_meetings.length})
</summary>
<div class="mt-2 space-y-1 text-xs font-mono">
{#each known_meetings as meeting_name (meeting_name)}
<div class="bg-surface-200-800 rounded px-2 py-1">
{meeting_name}
</div>
{/each}
</div>
</details>
</div>
</div>
{/if}
{#if meetings_loading} {#if meetings_loading}
<!-- Loading skeleton --> <!-- Loading skeleton -->
<div <div