3 Commits

Author SHA1 Message Date
Scott Idem
392217e66c Refine Jitsi report edit-mode controls 2026-05-06 12:10:41 -04:00
Scott Idem
7497bfb9f8 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>
2026-05-06 11:47:43 -04:00
Scott Idem
3ae9d0a884 Refine IDAA Jitsi reports UX
Add Novi UUID exclusion and known-meeting filtering, default the report date range to the last 60 days, and hide Room Name unless global edit mode is enabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 10:39:42 -04:00
7 changed files with 600 additions and 252 deletions

View File

@@ -183,11 +183,16 @@ fetch(`${api_root_url}/customers/${uuid}`, {
- **`novi_bb_base_url`**: (optional) Base URL used to build links for Bulletin Board notification emails.
- **`jitsi_exclude_names`**: (optional) Array of display name strings to exclude from Jitsi Reports.
Used to hide known staff and test accounts from the activity report so participant counts and lists
reflect real member activity only. Names are matched case-insensitively against `final_participants[].displayName`.
Example: `["Scott I.", "Michelle V.", "Brie K."]`. The filter is applied before the "Real meetings only"
threshold check — a session with only excluded participants is treated as having 0 real participants.
- **`jitsi_exclude_uuids`**: (optional) Array of Novi UUIDs to exclude from Jitsi Reports.
This is the canonical staff/test filter. UUIDs are matched case-insensitively against
`final_participants[].novi_uuid` when present. Example: `["uuid-1", "uuid-2"]`.
- **`jitsi_known_meetings`**: (optional) Array of meeting names / room names to keep in the report.
When this list is non-empty, only matching `room_name` values are shown. Matching is
case-insensitive.
- **Legacy fallback:** `jitsi_exclude_names` is still honored for older configs, but it should be
migrated to UUIDs.
- **Email config values** (`noreply_email`, `noreply_name`, `admin_email`, `admin_name`): used by functions that send notification emails (BB posts, comments, recovery meetings).
@@ -461,7 +466,7 @@ Moderation permissions are controlled by `novi_jitsi_mod_li` in the IDAA store.
An admin/staff reporting tool that aggregates raw Jitsi activity logs into human-readable meeting sessions. It is **not** a member-facing page — IDAA members do not see it.
**Reminder:** this page is still incomplete. We still need the Novi UUID filter to work and we still need meeting-name whitelist filtering.
**Reminder:** this page now filters staff by Novi UUID and can whitelist known meeting names from site config.
### View Modes
@@ -478,32 +483,45 @@ Both modes use the same filtered data set — switching views does not reset fil
| Filter | Default | Logic |
| --- | --- | --- |
| **Real meetings only** | off | Show only sessions where `real_participant_count >= 2` OR `duration > 5 min`. Applied **after** staff exclusion (see below). |
| **Min. Participants** | 0 | Minimum `real_participant_count` to display a session. |
| **Room Name** | (empty) | Case-insensitive substring match against `room_name`. |
| **From / To** | (empty) | Date range applied to `start_time`. "To" date includes the full end of day. |
| **Min. Participants** | 2 | Minimum `real_participant_count` to display a session. Used as the only size filter. |
| **Room Name** | edit mode only | Case-insensitive substring match against `room_name`. Hidden unless AE global edit mode is on. |
| **From / To** | last 60 days / today | Date range applied to `start_time`. "To" date includes the full end of day. |
A "Reset Filters" button appears whenever any filter is non-default.
### Staff / Test Exclusion
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`
**Problem:** Staff and test accounts (Scott, Michelle, Brie) join real member meetings for setup, testing, and tech support. Their presence inflates participant counts and pollutes the participant list.
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.
**Solution:** A configurable exclusion list in `site_cfg_json`:
### Staff / Meeting Filtering
**Problem:** Staff/test accounts and one-off test rooms distort the reports.
**Site config keys:**
```json
{ "jitsi_exclude_names": ["Scott I.", "Michelle V.", "Brie K."] }
{
"jitsi_exclude_uuids": ["uuid-1", "uuid-2"],
"jitsi_known_meetings": ["IDAA-BIPOC-Meeting", "IDAA-Sunday-Meeting"]
}
```
**How it works (client-side only, no backend change needed):**
**How it works:**
1. On load, the page reads `$ae_loc.site_cfg_json?.jitsi_exclude_names` (string array, defaults to `[]`).
2. For every session, a `real_participants` derived list is computed by filtering `final_participants` against the exclusion list (case-insensitive display name match).
3. `real_participant_count = real_participants.length` — this count drives all filters, stats, and the participant list column in grouped view.
4. The raw `final_participant_count` from the API is never shown to the user once an exclusion list is configured.
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.
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.
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.
**Why display-name matching (not Novi UUID):** Jitsi participant data (`meta_json.participants`) only contains `displayName` and `role` — the Novi UUID is not passed through to the activity log. UUID-based exclusion would require a Jitsi config change plus a backend schema update and is deferred. Display names for OSIT staff are stable and controlled.
**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.`
**Still pending:** the new Jitsi Reports page still needs a real Novi UUID filter and a whitelist of meeting names so staff can narrow the report set without relying only on display names.
**Note:** matching is case-insensitive on the stored `room_name` / meeting name.
### Summary Stats
@@ -514,7 +532,13 @@ Shown above the meeting list when data is loaded. Stats reflect the **filtered +
- **Avg Duration** — mean session duration (HH:MM:SS)
- **Total Duration** — sum of all session durations (HH:MM:SS)
In grouped view, each room header also shows its own subtotals (meeting count, unique participants).
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
@@ -733,4 +757,4 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
---
**Document Status:** ✅ Current
**Last Verified:** 2026-05-05 — added Module 5: Jitsi Reports (grouped view, real-meetings filter, staff exclusion via `jitsi_exclude_names`); fixed route tree (`jitsi_reports/` is inside `(idaa)/`)
**Last Verified:** 2026-05-06 — added Module 5: Jitsi Reports (grouped view, UUID exclusion, known-meeting whitelist, UUID-based unique counts); fixed route tree (`jitsi_reports/` is inside `(idaa)/`)

View File

@@ -185,9 +185,8 @@ suddenly jumps to 0 errors, verify it's not because a bad `.d.ts` replaced a pac
effect without re-login.
### [IDAA] Jitsi Reports still incomplete
- [ ] **Finish Jitsi Reports filters**the new Jitsi Reports page still needs a working Novi UUID
filter and meeting-name whitelist filtering so staff can narrow the report set without relying on
display-name matching alone.
- [x] **Finish Jitsi Reports filters**added Novi UUID exclusion plus meeting-name whitelist
filtering, with room-level unique counts based on Novi UUID when present. (2026-05-06)
### [PWA] Service worker ignoring `chrome-extension://` requests
Browser console shows repeated errors:

View File

@@ -51,9 +51,9 @@ let show_llm_api_token = $state(false);
// Ensure we have a valid object
if (!cfg_json) cfg_json = {};
function add_to_list(key: string) {
function add_to_list(key: string, prompt_label: string) {
if (!cfg_json[key]) cfg_json[key] = [];
const val = prompt('Enter Novi UUID:');
const val = prompt(prompt_label);
if (val) cfg_json[key].push(val);
}
@@ -61,6 +61,10 @@ function remove_from_list(key: string, index: number) {
cfg_json[key].splice(index, 1);
}
function list_count(key: string): number {
return Array.isArray(cfg_json[key]) ? cfg_json[key].length : 0;
}
// Sync Raw JSON string when entering the tab
$effect(() => {
if (active_tab === 'raw') {
@@ -365,24 +369,32 @@ $effect(() => {
<!-- UUID Lists -->
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each [{ key: 'novi_admin_li', label: 'Novi Admins', color: 'text-error-500' }, { key: 'novi_trusted_li', label: 'Novi Trusted', color: 'text-warning-500' }, { key: 'novi_jitsi_mod_li', label: 'Jitsi Moderators', color: 'text-primary-500' }, { key: 'novi_idaa_group_guid_li', label: 'Member Group GUIDs', color: 'text-secondary-500' }] as list (list.key)}
<div class="bg-surface-500/5 space-y-2 rounded-lg p-3">
<header class="flex items-center justify-between">
<span
class="text-[10px] font-black tracking-wider uppercase {list.color}"
>{list.label}</span>
{#each [{ key: 'novi_admin_li', label: 'Novi Admins', color: 'text-error-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_trusted_li', label: 'Novi Trusted', color: 'text-warning-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_jitsi_mod_li', label: 'Jitsi Moderators', color: 'text-primary-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_idaa_group_guid_li', label: 'Member Group GUIDs', color: 'text-secondary-500', prompt: 'Enter Group GUID:' }, { key: 'jitsi_exclude_uuids', label: 'Jitsi Excluded UUIDs', color: 'text-error-500', prompt: 'Enter Novi UUID to exclude:' }, { key: 'jitsi_known_meetings', label: 'Known IDAA Meetings', color: 'text-primary-500', prompt: 'Enter meeting name to allow:' }] as list (list.key)}
<details class="bg-surface-500/5 rounded-lg p-3">
<summary
class="flex cursor-pointer list-none items-center justify-between gap-2 [&::-webkit-details-marker]:hidden">
<span class="min-w-0">
<span
class="text-[10px] font-black tracking-wider uppercase {list.color}"
>{list.label}</span>
<span class="ml-2 text-[10px] opacity-50"
>({list_count(list.key)})</span>
</span>
<button
class="btn btn-icon btn-icon-sm variant-soft-primary"
onclick={() => add_to_list(list.key)}>
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
add_to_list(list.key, list.prompt);
}}>
<Plus size="12" />
</button>
</header>
<div class="space-y-1">
{#each cfg_json[list.key] ?? [] as uuid, i (uuid)}
</summary>
<div class="mt-3 space-y-1">
{#each cfg_json[list.key] ?? [] as item, i (i)}
<div
class="bg-surface-500/10 flex items-center gap-1 rounded p-1 font-mono text-[10px]">
<span class="grow truncate"
>{uuid}</span>
<span class="grow truncate">{item}</span>
<button
class="text-error-500 transition-transform hover:scale-110"
onclick={() =>
@@ -392,7 +404,7 @@ $effect(() => {
</div>
{/each}
</div>
</div>
</details>
{/each}
</section>

View File

@@ -270,6 +270,9 @@ export const properties_to_save = [
'user_id',
'user_id_random',
'external_client_id',
'url_root',
'url_full_path',
'url_params',
'source',
'object_type',
'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_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.
function safe_parse_meta(raw: unknown): Record<string, unknown> {
if (!raw) return {};
@@ -24,6 +46,49 @@ function parse_duration_seconds(d: string): number {
return (h || 0) * 3600 + (m || 0) * 60 + (s || 0);
}
function normalize_text(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalize_uuid(value: unknown): string {
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 {
const nested_user = source.user as Record<string, unknown> | undefined;
const nested_context = source.context as Record<string, unknown> | undefined;
const nested_context_user = nested_context?.user as
| Record<string, unknown>
| undefined;
const candidates = [
source.novi_uuid,
source.uuid,
source.novi_customer_uid,
nested_user?.novi_uuid,
nested_user?.uuid,
nested_context_user?.novi_uuid,
nested_context_user?.uuid
];
for (const candidate of candidates) {
const uuid = normalize_uuid(candidate);
if (uuid) return uuid;
}
return '';
}
/**
* @description Queries all Jitsi-related activity logs and processes them into a structured report,
* grouped by meeting ID.
@@ -36,6 +101,236 @@ function parse_duration_seconds(d: string): number {
* @param log_lvl The logging level.
* @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({
api_cfg,
account_id,
@@ -112,140 +407,7 @@ export async function qry__jitsi_report({
});
}
// Step 2: Process the flat list into a structured report.
//
// 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, role> per meeting, deduplicating by name.
//
// 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 → role
const participant_maps = new Map<string, Map<string, string>>();
// 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<{ displayName?: string; role?: string }>
| undefined;
if (Array.isArray(snapshot)) {
for (const p of snapshot) {
if (!p.displayName) continue;
// Only overwrite an existing entry if we're upgrading from participant → moderator
const existing_role = p_map.get(p.displayName);
if (!existing_role || (existing_role !== 'moderator' && p.role === 'moderator')) {
p_map.set(p.displayName, p.role ?? 'participant');
}
}
}
}
// Collect participants from join events (may catch people who left before the snapshot)
if (log.action === 'jitsi_meeting_participant_joined') {
const full_name = (meta.full_name as string) || '';
if (full_name && !p_map.has(full_name)) {
p_map.set(full_name, (meta.role as string) ?? 'participant');
}
}
// 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.entries())
.map(([displayName, role]) => ({ displayName, role }))
.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;
return build_jitsi_report_from_logs(flat_log_list, log_lvl);
}
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;
source?: string;
url_root?: string;
url_full_path?: string;
url_params?: string;
object_type?: string;
object_id_random?: string;

View File

@@ -1,7 +1,10 @@
<script lang="ts">
import { untrack } from 'svelte';
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 { idaa_loc } from '$lib/stores/ae_idaa_stores';
import JitsiUrlBuilder from './ae_idaa_comp__jitsi_url_builder.svelte';
@@ -15,6 +18,7 @@ interface MeetingEvent {
interface MeetingParticipant {
displayName: string;
role: string;
novi_uuid?: string;
}
interface MeetingReport {
@@ -39,6 +43,45 @@ let meetings_loading = $state(true);
let meetings_error = $state<string | null>(null);
let meetings_load_started = $state(false);
function normalize_text(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalize_uuid(value: unknown): string {
return normalize_text(value).toLowerCase();
}
function normalize_meeting_name(value: unknown): string {
return normalize_text(value).toLowerCase();
}
function normalize_list(value: unknown): string[] {
return Array.isArray(value)
? value
.map((item) => normalize_text(item))
.filter((item) => item.length > 0)
: [];
}
function participant_identity(p: MeetingParticipant): string {
const uuid = normalize_uuid(p.novi_uuid);
if (uuid) return `uuid:${uuid}`;
return `name:${normalize_meeting_name(p.displayName)}`;
}
function format_date_input_value(date: Date): string {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
const today = new Date();
const default_filter_date_to = format_date_input_value(today);
const default_filter_date_from = format_date_input_value(
new Date(today.getFullYear(), today.getMonth(), today.getDate() - 60)
);
$effect(() => {
if (meetings_load_started) return;
if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return;
@@ -52,31 +95,75 @@ $effect(() => {
meetings_error = null;
untrack(() => {
void load_jitsi_report({
api_cfg,
account_id,
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;
void (async () => {
const cached = await load_jitsi_report_from_cache({
account_id,
log_lvl: 1
});
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;
}
})();
});
});
// --- Staff/test exclusion ---
// Read display names to exclude from site_cfg_json.jitsi_exclude_names.
// Matched case-insensitively against MeetingParticipant.displayName.
let exclude_names = $derived<string[]>(
(($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_names as string[] | undefined)
?.map((n) => n.toLowerCase().trim()) ?? []
// UUID-based exclusion is the canonical path; the legacy name list stays as a fallback
// for older site config values until those are cleaned up.
let exclude_uuids = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_uuids
).map((uuid) => uuid.toLowerCase())
);
const temp_excluded_names = ['Scott I.', 'Brie P.', 'Michelle V.'];
let exclude_names = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_names
).map((name) => name.toLowerCase())
);
let known_meetings = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_known_meetings
).map((name) => name.toLowerCase())
);
let exclude_uuid_set = $derived(new Set(exclude_uuids));
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 show_excluded_uuids = $state(false);
let show_all_meetings = $state(false);
// Apply exclusion to every meeting — produces the "real" participant list
// used by all downstream filters, stats, and display.
@@ -84,15 +171,23 @@ let meetings_enriched = $derived<MeetingReportEnriched[]>(
meetings_all.map((m) => {
// final_participants can be null/undefined if meta_json.participants was null in the API response
const all_participants: MeetingParticipant[] = m.final_participants ?? [];
const real_participants =
exclude_names.length > 0
? all_participants.filter(
(p) =>
!exclude_names.includes(
p.displayName.toLowerCase().trim()
)
)
: all_participants;
const real_participants = all_participants.filter((p) => {
if (show_excluded_uuids) return true;
const participant_uuid = normalize_uuid(p.novi_uuid);
if (participant_uuid && exclude_uuid_set.has(participant_uuid)) {
return false;
}
const participant_name = normalize_meeting_name(p.displayName);
if (
exclude_name_set.has(participant_name) ||
temp_excluded_name_set.has(participant_name)
) {
return false;
}
return true;
});
return {
...m,
real_participants,
@@ -105,26 +200,27 @@ let meetings_enriched = $derived<MeetingReportEnriched[]>(
let group_by_room = $state(true);
// --- Filter state ---
let filter_real_only = $state(false);
let filter_min_participants = $state(0);
let filter_min_participants = $state(2);
let filter_room_name = $state('');
let filter_date_from = $state('');
let filter_date_to = $state('');
let filter_date_from = $state(default_filter_date_from);
let filter_date_to = $state(default_filter_date_to);
let filters_are_modified = $derived(
filter_real_only ||
filter_min_participants !== 0 ||
filter_room_name !== '' ||
filter_date_from !== '' ||
filter_date_to !== ''
filter_min_participants !== 2 ||
(filter_room_name !== '' && $ae_loc.edit_mode) ||
show_excluded_uuids ||
show_all_meetings ||
filter_date_from !== default_filter_date_from ||
filter_date_to !== default_filter_date_to
);
function reset_filters() {
filter_real_only = false;
filter_min_participants = 0;
filter_min_participants = 2;
filter_room_name = '';
filter_date_from = '';
filter_date_to = '';
show_excluded_uuids = false;
show_all_meetings = false;
filter_date_from = default_filter_date_from;
filter_date_to = default_filter_date_to;
}
// --- Duration helpers ---
@@ -154,16 +250,20 @@ function compute_end_time(start_time: string, duration: string): string {
// All counts and thresholds use real_participant_count (post-exclusion).
let meetings_filtered = $derived.by(() => {
return meetings_enriched.filter((m) => {
if (filter_real_only) {
const dur_secs = parse_duration_seconds(m.final_duration);
if (m.real_participant_count < 2 && dur_secs <= 300) return false;
if (!show_all_meetings && known_meeting_set.size > 0) {
if (!known_meeting_set.has(normalize_meeting_name(m.room_name))) {
return false;
}
}
if (m.real_participant_count < filter_min_participants) return false;
if (
filter_room_name &&
!m.room_name?.toLowerCase().includes(filter_room_name.toLowerCase())
)
return false;
if ($ae_loc.edit_mode && filter_room_name) {
if (
!m.room_name
?.toLowerCase()
.includes(filter_room_name.toLowerCase())
)
return false;
}
if (filter_date_from) {
if (Date.parse(m.start_time) < Date.parse(filter_date_from))
return false;
@@ -230,7 +330,7 @@ function room_stats(sessions: MeetingReportEnriched[]) {
0
);
const unique_names = new Set(
sessions.flatMap((m) => m.real_participants.map((p) => p.displayName))
sessions.flatMap((m) => m.real_participants.map((p) => participant_identity(p)))
);
return {
meeting_count: sessions.length,
@@ -371,8 +471,8 @@ function export_json() {
</div>
</div>
<!-- Jitsi URL Builder — trusted_access only -->
{#if $ae_loc.trusted_access}
<!-- Jitsi URL Builder — edit mode only -->
{#if $ae_loc.edit_mode && $ae_loc.trusted_access}
<div
class="bg-surface-100-900 border-surface-200-800 overflow-hidden rounded-xl border">
<button
@@ -400,16 +500,25 @@ function export_json() {
<!-- Filter bar -->
<div
class="bg-surface-100-900 border-surface-200-800 flex flex-row flex-wrap items-end gap-3 rounded-xl border p-3">
<!-- Real meetings only toggle -->
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
<input
type="checkbox"
bind:checked={filter_real_only}
class="checkbox checkbox-sm" />
<span class="text-sm font-medium whitespace-nowrap"
>Real meetings only</span>
</label>
<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>
<label
for="filter_min_p"
@@ -423,19 +532,21 @@ function export_json() {
bind:value={filter_min_participants}
class="border-surface-200-800 bg-surface-50-950 w-20 rounded border px-2 py-1" />
</div>
<div>
<label
for="filter_room"
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
Room Name
</label>
<input
type="text"
id="filter_room"
placeholder="Search rooms..."
bind:value={filter_room_name}
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
</div>
{#if $ae_loc.edit_mode}
<div>
<label
for="filter_room"
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
Room Name
</label>
<input
type="text"
id="filter_room"
placeholder="Search rooms..."
bind:value={filter_room_name}
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
</div>
{/if}
<div>
<label
for="filter_date_from"
@@ -472,6 +583,40 @@ function export_json() {
{/if}
</div>
{#if $ae_loc.edit_mode && (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}
<!-- Loading skeleton -->
<div
@@ -564,7 +709,7 @@ function export_json() {
? 'session'
: 'sessions'}
</span>
<span>
<span title="Unique participants (by Novi UUID when available)">
<span
class="fas fa-users mr-1"
aria-hidden="true"></span>